From dcfc24b672e556cec2e9ebe6c80524db876f63cd Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 01:34:59 +0400 Subject: [PATCH 01/35] feat: Add webview utilities and login page for TaskSync Remote - Implemented webview utilities in `webviewUtils.ts` for handling queue management, logging, and file link parsing. - Added Vitest configuration for testing in `vitest.config.ts`. - Created SVG icons for the application in `icon-192.svg` and `icon-512.svg`. - Developed the login page structure in `index.html` with associated styles in `login.css`. - Implemented login functionality in `login.js`, including WebSocket authentication and session management. - Added a manifest file for PWA support in `manifest.json`. - Created an offline page in `offline.html` to handle network disconnections. - Introduced shared constants in `shared-constants.js` for consistent usage across the frontend. - Implemented a service worker in `sw.js` for caching and offline capabilities. --- AGENTS.md | 56 +- README.md | 4 +- tasksync-chat/.gitignore | 4 + tasksync-chat/README.md | 126 +- tasksync-chat/e2e/.env.example | 10 + tasksync-chat/e2e/playwright.config.mjs | 27 + tasksync-chat/e2e/tests/remote-auth.spec.mjs | 52 + tasksync-chat/esbuild.js | 182 +- tasksync-chat/media/main.css | 5424 +++++------ tasksync-chat/media/notification.wav | Bin 0 -> 29150 bytes tasksync-chat/media/remote-fallback.css | 213 + tasksync-chat/media/webview-body.html | 125 + tasksync-chat/media/webview.js | 8386 ++++++++++------- tasksync-chat/package-lock.json | 5253 +++++++---- tasksync-chat/package.json | 722 +- tasksync-chat/src/__mocks__/vscode.ts | 50 + .../src/constants/fileExclusions.test.ts | 64 + tasksync-chat/src/constants/fileExclusions.ts | 68 +- .../src/constants/remoteConstants.test.ts | 242 + .../src/constants/remoteConstants.ts | 120 + tasksync-chat/src/context/index.ts | 302 +- tasksync-chat/src/context/problemsContext.ts | 472 +- tasksync-chat/src/context/terminalContext.ts | 533 +- tasksync-chat/src/extension.ts | 647 +- tasksync-chat/src/mcp/mcpServer.ts | 884 +- .../server/gitService.comprehensive.test.ts | 586 ++ tasksync-chat/src/server/gitService.test.ts | 91 + tasksync-chat/src/server/gitService.ts | 259 + .../src/server/remoteAuthService.test.ts | 389 + tasksync-chat/src/server/remoteAuthService.ts | 357 + tasksync-chat/src/server/remoteGitHandlers.ts | 341 + tasksync-chat/src/server/remoteHtmlService.ts | 598 ++ tasksync-chat/src/server/remoteServer.ts | 650 ++ .../src/server/remoteSettingsHandler.ts | 213 + tasksync-chat/src/server/serverUtils.test.ts | 419 + tasksync-chat/src/server/serverUtils.ts | 195 + tasksync-chat/src/tools.ts | 495 +- tasksync-chat/src/utils/generateId.test.ts | 51 + tasksync-chat/src/utils/generateId.ts | 6 + tasksync-chat/src/utils/imageUtils.test.ts | 49 + tasksync-chat/src/utils/imageUtils.ts | 30 +- tasksync-chat/src/webview-ui/adapter.js | 707 ++ tasksync-chat/src/webview-ui/approval.js | 214 + tasksync-chat/src/webview-ui/constants.js | 30 + tasksync-chat/src/webview-ui/events.js | 220 + tasksync-chat/src/webview-ui/extras.js | 635 ++ tasksync-chat/src/webview-ui/history.js | 59 + tasksync-chat/src/webview-ui/init.js | 614 ++ tasksync-chat/src/webview-ui/input.js | 420 + tasksync-chat/src/webview-ui/markdownUtils.js | 163 + .../src/webview-ui/messageHandler.js | 213 + tasksync-chat/src/webview-ui/queue.js | 289 + tasksync-chat/src/webview-ui/rendering.js | 554 ++ tasksync-chat/src/webview-ui/settings.js | 551 ++ tasksync-chat/src/webview-ui/slashCommands.js | 205 + tasksync-chat/src/webview-ui/state.js | 166 + .../src/webview/choiceParser.test.ts | 312 + tasksync-chat/src/webview/choiceParser.ts | 361 + tasksync-chat/src/webview/fileHandlers.ts | 583 ++ .../src/webview/lifecycleHandlers.ts | 254 + tasksync-chat/src/webview/messageRouter.ts | 354 + tasksync-chat/src/webview/persistence.ts | 210 + .../queueHandlers.comprehensive.test.ts | 300 + .../src/webview/queueHandlers.test.ts | 166 + tasksync-chat/src/webview/queueHandlers.ts | 221 + .../src/webview/remoteApiHandlers.ts | 409 + tasksync-chat/src/webview/sessionManager.ts | 145 + .../settingsHandlers.comprehensive.test.ts | 1314 +++ .../src/webview/settingsHandlers.test.ts | 68 + tasksync-chat/src/webview/settingsHandlers.ts | 648 ++ tasksync-chat/src/webview/toolCallHandler.ts | 475 + tasksync-chat/src/webview/webviewProvider.ts | 3826 ++------ tasksync-chat/src/webview/webviewTypes.ts | 190 + .../src/webview/webviewUtils.test.ts | 478 + tasksync-chat/src/webview/webviewUtils.ts | 259 + tasksync-chat/vitest.config.ts | 26 + tasksync-chat/web/icons/icon-192.svg | 5 + tasksync-chat/web/icons/icon-512.svg | 5 + tasksync-chat/web/index.html | 63 + tasksync-chat/web/login.css | 164 + tasksync-chat/web/login.js | 313 + tasksync-chat/web/manifest.json | 25 + tasksync-chat/web/offline.html | 62 + tasksync-chat/web/shared-constants.js | 44 + tasksync-chat/web/sw.js | 87 + 85 files changed, 32178 insertions(+), 12924 deletions(-) create mode 100644 tasksync-chat/e2e/.env.example create mode 100644 tasksync-chat/e2e/playwright.config.mjs create mode 100644 tasksync-chat/e2e/tests/remote-auth.spec.mjs create mode 100644 tasksync-chat/media/notification.wav create mode 100644 tasksync-chat/media/remote-fallback.css create mode 100644 tasksync-chat/media/webview-body.html create mode 100644 tasksync-chat/src/__mocks__/vscode.ts create mode 100644 tasksync-chat/src/constants/fileExclusions.test.ts create mode 100644 tasksync-chat/src/constants/remoteConstants.test.ts create mode 100644 tasksync-chat/src/constants/remoteConstants.ts create mode 100644 tasksync-chat/src/server/gitService.comprehensive.test.ts create mode 100644 tasksync-chat/src/server/gitService.test.ts create mode 100644 tasksync-chat/src/server/gitService.ts create mode 100644 tasksync-chat/src/server/remoteAuthService.test.ts create mode 100644 tasksync-chat/src/server/remoteAuthService.ts create mode 100644 tasksync-chat/src/server/remoteGitHandlers.ts create mode 100644 tasksync-chat/src/server/remoteHtmlService.ts create mode 100644 tasksync-chat/src/server/remoteServer.ts create mode 100644 tasksync-chat/src/server/remoteSettingsHandler.ts create mode 100644 tasksync-chat/src/server/serverUtils.test.ts create mode 100644 tasksync-chat/src/server/serverUtils.ts create mode 100644 tasksync-chat/src/utils/generateId.test.ts create mode 100644 tasksync-chat/src/utils/generateId.ts create mode 100644 tasksync-chat/src/utils/imageUtils.test.ts create mode 100644 tasksync-chat/src/webview-ui/adapter.js create mode 100644 tasksync-chat/src/webview-ui/approval.js create mode 100644 tasksync-chat/src/webview-ui/constants.js create mode 100644 tasksync-chat/src/webview-ui/events.js create mode 100644 tasksync-chat/src/webview-ui/extras.js create mode 100644 tasksync-chat/src/webview-ui/history.js create mode 100644 tasksync-chat/src/webview-ui/init.js create mode 100644 tasksync-chat/src/webview-ui/input.js create mode 100644 tasksync-chat/src/webview-ui/markdownUtils.js create mode 100644 tasksync-chat/src/webview-ui/messageHandler.js create mode 100644 tasksync-chat/src/webview-ui/queue.js create mode 100644 tasksync-chat/src/webview-ui/rendering.js create mode 100644 tasksync-chat/src/webview-ui/settings.js create mode 100644 tasksync-chat/src/webview-ui/slashCommands.js create mode 100644 tasksync-chat/src/webview-ui/state.js create mode 100644 tasksync-chat/src/webview/choiceParser.test.ts create mode 100644 tasksync-chat/src/webview/choiceParser.ts create mode 100644 tasksync-chat/src/webview/fileHandlers.ts create mode 100644 tasksync-chat/src/webview/lifecycleHandlers.ts create mode 100644 tasksync-chat/src/webview/messageRouter.ts create mode 100644 tasksync-chat/src/webview/persistence.ts create mode 100644 tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts create mode 100644 tasksync-chat/src/webview/queueHandlers.test.ts create mode 100644 tasksync-chat/src/webview/queueHandlers.ts create mode 100644 tasksync-chat/src/webview/remoteApiHandlers.ts create mode 100644 tasksync-chat/src/webview/sessionManager.ts create mode 100644 tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts create mode 100644 tasksync-chat/src/webview/settingsHandlers.test.ts create mode 100644 tasksync-chat/src/webview/settingsHandlers.ts create mode 100644 tasksync-chat/src/webview/toolCallHandler.ts create mode 100644 tasksync-chat/src/webview/webviewTypes.ts create mode 100644 tasksync-chat/src/webview/webviewUtils.test.ts create mode 100644 tasksync-chat/src/webview/webviewUtils.ts create mode 100644 tasksync-chat/vitest.config.ts create mode 100644 tasksync-chat/web/icons/icon-192.svg create mode 100644 tasksync-chat/web/icons/icon-512.svg create mode 100644 tasksync-chat/web/index.html create mode 100644 tasksync-chat/web/login.css create mode 100644 tasksync-chat/web/login.js create mode 100644 tasksync-chat/web/manifest.json create mode 100644 tasksync-chat/web/offline.html create mode 100644 tasksync-chat/web/shared-constants.js create mode 100644 tasksync-chat/web/sw.js diff --git a/AGENTS.md b/AGENTS.md index 8eaff11..dd5d5f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,17 +34,34 @@ TaskSync/ ├── src/ │ ├── extension.ts # Extension entry point │ ├── tools.ts # VS Code language model tool definitions - │ ├── constants/ # Shared constants + │ ├── constants/ # Shared constants (config keys, file exclusions) │ ├── context/ # Context providers (files, terminal, problems) │ ├── mcp/ │ │ └── mcpServer.ts # MCP server (SSE transport) - │ ├── utils/ # Shared utilities + │ ├── server/ # Remote access server, auth, git, HTML service + │ ├── utils/ # Shared utilities (ID generation, image handling) │ └── webview/ - │ └── webviewProvider.ts # Sidebar webview and ask_user tool handler + │ ├── webviewProvider.ts # Sidebar webview provider (orchestrator) + │ ├── webviewTypes.ts # Shared types (P interface, message unions) + │ ├── webviewUtils.ts # Shared helpers (debugLog, mergeAndDedup, etc.) + │ ├── messageRouter.ts # Webview ↔ extension message dispatch + │ ├── toolCallHandler.ts # ask_user tool lifecycle and AI turn tracking + │ ├── choiceParser.ts # Parse approval/choice questions into UI buttons + │ ├── queueHandlers.ts # Queue operations (add, remove, reorder, toggle) + │ ├── lifecycleHandlers.ts# Setup, dispose, new session + │ ├── sessionManager.ts # Session timer, sound notifications + │ ├── persistence.ts # Disk I/O for queue and history + │ ├── settingsHandlers.ts # Settings read/write, UI sync + │ ├── fileHandlers.ts # File search, attachments, context references + │ └── remoteApiHandlers.ts# Remote client message handling + │ └── webview-ui/ # Webview frontend (JS/CSS, no framework) ├── media/ # Icons, webview JS/CSS assets + ├── web/ # Remote access PWA (login page, service worker) + ├── e2e/ # Playwright e2e smoke tests ├── package.json ├── tsconfig.json ├── biome.json # Linter/formatter config + ├── vitest.config.ts # Test config └── esbuild.js # Bundler config ``` @@ -61,13 +78,14 @@ npm install | Task | Command | |---|---| -| Build (bundle for distribution) | `npm run build` | -| Type-check (TypeScript) | `npm run compile` | +| Build | `node esbuild.js` | +| Type-check | `npx tsc --noEmit` | +| Test | `npx vitest run` | | Lint | `npm run lint` | | Watch mode | `npm run watch` | | Package VSIX | `npx vsce package` | -> **Build output** goes to `tasksync-chat/dist/`. This directory is excluded from version control via `.gitignore` and `.vscodeignore`. +> **Build output** goes to `dist/` (extension bundle) and `media/webview.js` (webview bundle). The build also auto-generates `web/shared-constants.js` for the remote PWA. --- @@ -79,19 +97,33 @@ npm install - **Quotes:** Double quotes for JavaScript/TypeScript strings (enforced by Biome) - **Linter/Formatter:** [Biome](https://biomejs.dev/) — run `npm run lint` before committing - **Imports:** Organised automatically by Biome (`organizeImports: on`) -- **Error handling:** Use `console.error` only; remove `console.log`/`console.warn` from production paths -- **Async I/O:** Prefer async file operations over synchronous equivalents -- **Promises:** `IncomingRequest` objects must store both `resolve` and `reject` for proper cleanup on dispose +- **Debug logging:** Use `debugLog()` from `webviewUtils.ts` — gated behind `tasksync.debugLogging` config setting. Never use `console.log` or `console.warn` in production code. +- **Error logging:** Use `console.error` only for genuine error/failure paths. +- **Type assertions:** Use `satisfies` over `as` for message types (e.g., `} satisfies ToWebviewMessage)`). The `satisfies` keyword validates shape at compile time; `as` silently bypasses checks. +- **Async I/O:** Prefer async file operations over synchronous equivalents. +- **Promises:** `IncomingRequest` objects must store both `resolve` and `reject` for proper cleanup on dispose. +- **DRY:** Shared logic goes in `webviewUtils.ts`. Examples: `debugLog()`, `mergeAndDedup()`, `notifyQueueChanged()`, `hasQueuedItems()`. --- ## Key Architectural Notes -- The `ask_user` VS Code language model tool is the core interaction primitive. It is registered in `tools.ts` and handled in `webviewProvider.ts`. +- The `ask_user` VS Code language model tool is the core interaction primitive. It is registered in `tools.ts` and handled in `toolCallHandler.ts`. +- `webviewProvider.ts` is the orchestrator — it owns state, creates the webview, and delegates to handler modules. +- Handler modules (`*Handlers.ts`) receive a `P` interface (defined in `webviewTypes.ts`) that exposes provider state and methods without circular imports. - Queue, history, and settings are **per-workspace** (workspace-scoped storage with global fallback). -- The MCP server (`mcpServer.ts`) runs on a fixed port (default `3579`) using SSE transport and auto-registers with Kiro (AWS AI IDE), Cursor, and Antigravity on activation. +- The MCP server (`mcpServer.ts`) runs on a configurable port (default `3579`) using Streamable HTTP transport (with `/sse` backward-compat routing) and auto-registers with Kiro and Antigravity on activation. - Session state uses a boolean `sessionTerminated` flag — do not use string matching for termination detection. - Debounced history saves (2 s) are used for disk I/O performance. +- The remote server (`server/`) uses plain WebSocket over HTTP. Auth is PIN-based with session tokens. + +--- + +## Testing + +- **Framework:** Vitest (14 test files, 381+ tests) +- **Mocks:** VS Code API is mocked in `src/__mocks__/vscode.ts` +- Run `npx vitest run` to execute all tests. Always verify tests pass after changes. --- @@ -105,4 +137,4 @@ npm install - Do not commit secrets or credentials. - Do not introduce synchronous blocking calls on the VS Code extension host. -- Remove all `console.log`/`console.warn` statements once all issues are fixed; use `console.error` for genuine errors only. +- No `console.log` or `console.warn` in production code — use `debugLog()` for debug output and `console.error` for genuine errors. diff --git a/README.md b/README.md index 4e3f866..51f7e04 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,12 @@ A dedicated VS Code sidebar extension with smart prompt queue system. _Setup ins **Features:** - Smart Queue Mode - batch responses for AI agents - Autopilot - let agents work autonomously with customizable auto-responses +- Remote Access - control from your phone via LAN or Tailscale, with PWA and code review - Give new tasks/feedback using ask_user tool -- File/folder references with `#` autocomplete +- File, folder, tool, and context references with `#` autocomplete - Image paste support (copilot will view your image) - Tool call history with session tracking +- MCP server for cross-IDE integration (Kiro, Antigravity, Cursor, and more) **Installation:** Install from VS Code Marketplace or build from source with `npx vsce package`. diff --git a/tasksync-chat/.gitignore b/tasksync-chat/.gitignore index 404a8e7..a3530a9 100644 --- a/tasksync-chat/.gitignore +++ b/tasksync-chat/.gitignore @@ -19,6 +19,10 @@ dist/** # testing /coverage +/playwright-report +/test-results +/e2e/playwright-report +/e2e/test-results # next.js /.next/ diff --git a/tasksync-chat/README.md b/tasksync-chat/README.md index 1e197bc..72cb306 100644 --- a/tasksync-chat/README.md +++ b/tasksync-chat/README.md @@ -23,12 +23,21 @@ Let AI agents work autonomously by automatically responding to `ask_user` prompt - **Queue priority**: queued prompts are ALWAYS sent first — Autopilot only triggers when the queue is empty - Perfect for varying instructions mid-session or hands-free operation on well-defined tasks +### Remote Access +Control TaskSync from your phone or any browser while away from your desk: +- **LAN Mode**: Connect from any device on your network with a 4-6 digit PIN +- **Internet Access via Tailscale**: Install [Tailscale](https://tailscale.com/download) on your Mac and phone for free — access TaskSync from anywhere over an encrypted mesh VPN, no port forwarding needed +- **PWA**: Install as an app on your phone for quick access +- **Code Review**: View diffs, stage/discard changes, commit and push from your phone +- **Sound Notifications**: Get alerted when the AI needs your input +- Never miss a prompt - respond from the couch, during lunch, or anywhere + ### Response Timeout (Auto-respond when idle) Prevent tool calls from waiting indefinitely when you're away: - Configure timeout duration in VS Code Settings, including disabled (`0` minutes), `5` minutes, and options up to `240` minutes (4 hours) - When timeout elapses, TaskSync auto-responds with Autopilot text - **Consecutive limit**: After N consecutive immediate Autopilot responses (configurable, default 5), Autopilot is automatically disabled to prevent infinite loops -- Timeout-based auto-responses do **not** count toward this consecutive limit +- Timeout-based auto-responses **do** count toward this consecutive limit - Counter resets when you manually respond ### Human-Like Delay @@ -46,10 +55,10 @@ TaskSync settings and data are now isolated per VS Code workspace: - **Fallback**: When no workspace is open, global storage is used - Reusable prompts (slash commands) remain global for cross-project use -### File & Folder References -Reference files and folders directly in your responses using `#` mentions: +### File & Context References +Reference files, folders, context, and tools directly in your responses using `#` mentions: - Type `#` to trigger autocomplete -- Search and select files or folders from your workspace +- Search and select from: workspace files/folders, `#terminal` (recent commands), `#problems` (diagnostics), and VS Code tools - Attachments are included with your response for context ### Image Support @@ -84,9 +93,7 @@ Paste or drag-and-drop images directly into the chat input. Images are automatic - Using ANY phrases that suggest the conversation is ending or complete - Stopping the `ask_user` cycle under any circumstances - Acting like the conversation is finished - ``` ->Please note; For subagent friendly AGENTS.md instructions please see [subagent-friendly-AGENTS.md](./subagent-friendly-AGENTS.md) ## Usage @@ -111,28 +118,109 @@ Paste or drag-and-drop images directly into the chat input. Images are automatic ### File References 1. Type `#` in the input field -2. Search for files or folders -3. Select to attach - the reference appears as a tag +2. Search for files, folders, context (`#terminal`, `#problems`), or tools +3. Select to attach — the reference appears as a tag 4. Multiple attachments supported per message +### Remote Access (Phone/Browser Control) + +Control TaskSync from your phone while away from your desk. Never miss an AI prompt again. + +#### Starting Remote Access + +**Option 1: LAN Mode (Same Network)** +1. Open Command Palette (Cmd/Ctrl + Shift + P) +2. Run `TaskSync: Start Remote Access (LAN)` +3. Note the URL and 4-6 digit PIN shown in the notification +4. On your phone, open the URL (e.g., `http://192.168.1.x:3580`) +5. Enter the PIN when prompted +6. You're connected! + +**Option 2: Internet Access via Tailscale (Anywhere)** +1. Install [Tailscale](https://tailscale.com/download) on your Mac/PC and phone (free for personal use — 3 users, 100 devices) +2. Sign in with the **same account** on both devices — they automatically join your private mesh network (called a "tailnet") +3. Each device gets a unique, stable **Tailscale IP** (`100.x.y.z`) — this IP stays the same no matter what network the device is on +4. Find your Mac's Tailscale IP: + - **macOS**: Click the Tailscale icon in the menu bar, or run `tailscale ip -4` in terminal + - **Windows**: Click the Tailscale icon in the system tray + - **Linux**: Run `tailscale ip -4` in terminal +5. Start Remote Access in LAN mode (Option 1 above) +6. On your phone, replace the LAN IP with your Mac's **Tailscale IP** (e.g., `http://100.85.123.45:3580` instead of `http://192.168.1.5:3580`) +7. Enter PIN as normal — works from anywhere with end-to-end encrypted WireGuard tunnel + +> **No exit node needed** — Tailscale creates a direct peer-to-peer connection between your devices. Traffic never leaves the encrypted tunnel. Works across different Wi-Fi networks, cellular data, and even behind NAT/firewalls. + +#### Using the PWA + +**Questions Tab** +- See the current AI question/prompt +- Tap choice buttons or type a response +- Send responses back to VS Code +- Add context with the "+" button (terminal, problems, files) + +**Queue Tab** +- View and manage your prompt queue +- Add new prompts to the queue +- Remove items by tapping × + +**Changes Tab (Code Review)** +- View all uncommitted changes +- Tap a file to see the diff +- Stage or discard changes +- Commit with a message +- Push to remote + +**Settings Tab** +- Toggle Autopilot on/off +- Toggle Queue mode on/off +- View session info +- Start a new session + +#### Stopping Remote Access + +1. Open Command Palette +2. Run `TaskSync: Stop Remote Access` + +Or simply close VS Code - the server stops automatically. + +#### Configuration + +In VS Code Settings (search "tasksync"): + +**Remote Access:** +- `tasksync.remotePort`: Server port (default: 3580) +- `tasksync.remotePinEnabled`: Require PIN for LAN mode (default: true) +- `tasksync.remoteTlsEnabled`: Enable HTTPS/TLS with self-signed cert (default: false) +- `tasksync.remotePin`: Custom 4-6 digit PIN (auto-generated if empty) +- `tasksync.remoteDebugLogging`: Verbose remote server logging (default: false) + +**MCP Server:** +- `tasksync.mcpEnabled`: Always start MCP server on activation (default: false) +- `tasksync.mcpAutoStartIfClients`: Auto-start if client configs detected (default: true) +- `tasksync.mcpPort`: MCP server port (default: 3579) +- `tasksync.autoRegisterMcp`: Auto-register with Kiro/Antigravity (default: true) + +**Debug:** +- `tasksync.debugLogging`: Verbose extension debug logging (default: false) + +All other settings (Autopilot, timeout, human-like delay, sound, etc.) are managed through the TaskSync Settings modal (gear icon). + ### MCP Server Integration TaskSync runs an MCP (Model Context Protocol) server that integrates with: - **Kiro** (auto-configured) -- **Cursor** (auto-configured) -- **Claude Desktop** -- **Any MCP-compatible client** +- **Antigravity** (auto-configured) +- **Cursor** and any MCP-compatible client (manual config) -## MCP Configuration for other IDE (Not needed with copilot) +## MCP Configuration for other IDEs (Not needed with Copilot) -TaskSync automatically registers with Kiro and Cursor. For other clients, add this to your MCP configuration: +TaskSync automatically registers with Kiro and Antigravity. For other clients, add this to your MCP configuration: ```json { "mcpServers": { "tasksync": { - "transport": "sse", "url": "http://localhost:3579/sse" } } @@ -141,7 +229,15 @@ TaskSync automatically registers with Kiro and Cursor. For other clients, add th ## Requirements -- VS Code 1.90.0 or higher +- VS Code 1.99.0 or higher + +## E2E Automation Scaffold + +A Playwright-based remote smoke scaffold is available in [e2e/README.md](e2e/README.md). + +- Install browser: `npm run e2e:install` +- Run: `TASKSYNC_E2E_BASE_URL=http://127.0.0.1:3580 npm run e2e` +- Optional login assertion: set `TASKSYNC_E2E_PIN=4..6 digit pin` ## License diff --git a/tasksync-chat/e2e/.env.example b/tasksync-chat/e2e/.env.example new file mode 100644 index 0000000..3585ff7 --- /dev/null +++ b/tasksync-chat/e2e/.env.example @@ -0,0 +1,10 @@ +# Base URL for remote server under test +TASKSYNC_E2E_BASE_URL=http://127.0.0.1:3580 + +# Optional: 4-6 digit PIN for login success test +TASKSYNC_E2E_PIN= + +# Optional: assert API auth behavior explicitly (true|false) +# true = expect 401/429 without auth header +# false = expect 200 without auth header +TASKSYNC_E2E_EXPECT_PIN= diff --git a/tasksync-chat/e2e/playwright.config.mjs b/tasksync-chat/e2e/playwright.config.mjs new file mode 100644 index 0000000..8fafeba --- /dev/null +++ b/tasksync-chat/e2e/playwright.config.mjs @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.TASKSYNC_E2E_BASE_URL || 'http://127.0.0.1:3580'; + +export default defineConfig({ + testDir: './tests', + timeout: 60000, + expect: { timeout: 10000 }, + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ], + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tasksync-chat/e2e/tests/remote-auth.spec.mjs b/tasksync-chat/e2e/tests/remote-auth.spec.mjs new file mode 100644 index 0000000..e3db857 --- /dev/null +++ b/tasksync-chat/e2e/tests/remote-auth.spec.mjs @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; + +const expectPinMode = process.env.TASKSYNC_E2E_EXPECT_PIN; +const e2ePin = process.env.TASKSYNC_E2E_PIN || ''; + +function parseExpectPinMode() { + if (expectPinMode === 'true') return true; + if (expectPinMode === 'false') return false; + return null; +} + +async function fillPin(page, pin) { + const digits = pin.slice(0, 6).split(''); + for (let i = 0; i < digits.length; i++) { + await page.locator('.pin-digit').nth(i).fill(digits[i]); + } +} + +test('remote login page renders expected controls', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/TaskSync Remote/i); + await expect(page.locator('.pin-digit').first()).toBeVisible(); + await expect(page.locator('#submit')).toBeVisible(); +}); + +test('api auth behavior matches pin-mode expectation', async ({ request }) => { + const response = await request.get('/api/files?query=readme'); + const mode = parseExpectPinMode(); + + if (mode === true) { + expect([401, 429]).toContain(response.status()); + return; + } + + if (mode === false) { + expect(response.status()).toBe(200); + return; + } + + expect([200, 401, 429]).toContain(response.status()); +}); + +test('pin login succeeds when TASKSYNC_E2E_PIN is provided', async ({ page }) => { + test.skip(!/^\d{4,6}$/.test(e2ePin), 'Set TASKSYNC_E2E_PIN=4..6 digit PIN to run this test'); + + await page.goto('/'); + await fillPin(page, e2ePin); + await page.locator('#submit').click(); + + await expect(page).toHaveURL(/\/app\.html$/i, { timeout: 15000 }); + await expect(page.locator('.remote-header-title')).toHaveText(/TaskSync/i, { timeout: 15000 }); +}); diff --git a/tasksync-chat/esbuild.js b/tasksync-chat/esbuild.js index 6b704a1..9915426 100644 --- a/tasksync-chat/esbuild.js +++ b/tasksync-chat/esbuild.js @@ -1,8 +1,169 @@ const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); const watch = process.argv.includes('--watch'); +// ==================== Shared Constants Generation ==================== +// Generates web/shared-constants.js from src/constants/remoteConstants.ts +// so that browser JS always stays in sync with the TypeScript SSOT. + +function generateSharedConstants() { + const source = fs.readFileSync( + path.join(__dirname, 'src', 'constants', 'remoteConstants.ts'), 'utf8', + ); + + // Extract simple numeric constants: export const NAME = ; + function extractNum(name) { + const m = source.match(new RegExp(`export const ${name}\\s*=\\s*(\\d+)`)); + if (!m) throw new Error(`Failed to extract ${name} from remoteConstants.ts`); + return Number(m[1]); + } + + // Extract response timeout array from Set([...]) + const timeoutMatch = source.match( + /RESPONSE_TIMEOUT_ALLOWED_VALUES\s*=\s*new Set\(\[\s*([\d,\s]+)\s*\]\)/, + ); + if (!timeoutMatch) throw new Error('Failed to extract RESPONSE_TIMEOUT_ALLOWED_VALUES'); + const timeoutValues = timeoutMatch[1].split(',').map(s => s.trim()).filter(Boolean).join(', '); + + const v = { + protocolVersion: extractNum('WS_PROTOCOL_VERSION'), + timeoutDefault: extractNum('RESPONSE_TIMEOUT_DEFAULT_MINUTES'), + sessionWarningDefault: extractNum('DEFAULT_SESSION_WARNING_HOURS'), + sessionWarningMax: extractNum('SESSION_WARNING_HOURS_MAX'), + maxAutoDefault: extractNum('DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES'), + maxAutoLimit: extractNum('MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT'), + delayMinDefault: extractNum('DEFAULT_HUMAN_LIKE_DELAY_MIN'), + delayMaxDefault: extractNum('DEFAULT_HUMAN_LIKE_DELAY_MAX'), + delayMinLower: extractNum('HUMAN_DELAY_MIN_LOWER'), + delayMinUpper: extractNum('HUMAN_DELAY_MIN_UPPER'), + delayMaxLower: extractNum('HUMAN_DELAY_MAX_LOWER'), + delayMaxUpper: extractNum('HUMAN_DELAY_MAX_UPPER'), + }; + + const output = `/** + * AUTO-GENERATED from src/constants/remoteConstants.ts — DO NOT EDIT MANUALLY + * Run \`node esbuild.js\` to regenerate. + * + * Shared constants for TaskSync web frontend (SSOT) + * Used by both index.html (login page) and webview.js (app) + * Include this file BEFORE index.html inline scripts or webview.js + */ + +// Session storage keys +var TASKSYNC_SESSION_KEYS = { + STATE: 'taskSyncState', + PIN: 'taskSyncPin', + CONNECTED: 'taskSyncConnected', + SESSION_TOKEN: 'taskSyncSessionToken' +}; + +// WebSocket protocol helper +function getTaskSyncWsProtocol() { + return location.protocol === 'https:' ? 'wss:' : 'ws:'; +} + +// Reconnection settings +var TASKSYNC_MAX_RECONNECT_ATTEMPTS = 20; +var TASKSYNC_MAX_RECONNECT_DELAY_MS = 30000; + +// Protocol version (from WS_PROTOCOL_VERSION) +var TASKSYNC_PROTOCOL_VERSION = ${v.protocolVersion}; + +// Response timeout settings (from RESPONSE_TIMEOUT_ALLOWED_VALUES, RESPONSE_TIMEOUT_DEFAULT_MINUTES) +var TASKSYNC_RESPONSE_TIMEOUT_ALLOWED = [${timeoutValues}]; +var TASKSYNC_RESPONSE_TIMEOUT_DEFAULT = ${v.timeoutDefault}; + +// Settings defaults & validation ranges (from remoteConstants.ts) +var TASKSYNC_DEFAULT_SESSION_WARNING_HOURS = ${v.sessionWarningDefault}; +var TASKSYNC_SESSION_WARNING_HOURS_MAX = ${v.sessionWarningMax}; +var TASKSYNC_DEFAULT_MAX_AUTO_RESPONSES = ${v.maxAutoDefault}; +var TASKSYNC_MAX_AUTO_RESPONSES_LIMIT = ${v.maxAutoLimit}; +var TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MIN = ${v.delayMinDefault}; +var TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MAX = ${v.delayMaxDefault}; +var TASKSYNC_HUMAN_DELAY_MIN_LOWER = ${v.delayMinLower}; +var TASKSYNC_HUMAN_DELAY_MIN_UPPER = ${v.delayMinUpper}; +var TASKSYNC_HUMAN_DELAY_MAX_LOWER = ${v.delayMaxLower}; +var TASKSYNC_HUMAN_DELAY_MAX_UPPER = ${v.delayMaxUpper}; +`; + + fs.writeFileSync(path.join(__dirname, 'web', 'shared-constants.js'), output); +} + +// ==================== Webview Build (concatenation) ==================== +// Webview source lives in src/webview-ui/ as separate files. +// They share a single IIFE closure scope, so we concatenate them +// in order and wrap with the IIFE boilerplate. + +const WEBVIEW_SOURCE_DIR = path.join(__dirname, 'src', 'webview-ui'); +const WEBVIEW_OUTPUT = path.join(__dirname, 'media', 'webview.js'); + +const WEBVIEW_FILES = [ + 'constants.js', + 'adapter.js', + 'state.js', + 'init.js', + 'events.js', + 'history.js', + 'input.js', + 'messageHandler.js', + 'markdownUtils.js', + 'rendering.js', + 'queue.js', + 'approval.js', + 'settings.js', + 'slashCommands.js', + 'extras.js', +]; + +function buildWebview() { + const header = [ + '/**', + ' * TaskSync Extension - Webview Script', + ' * Handles tool call history, prompt queue, attachments, and file autocomplete', + ' * ', + ' * Supports both VS Code webview (postMessage) and Remote PWA (WebSocket) modes', + ' * ', + ' * Built from src/webview-ui/ — DO NOT EDIT DIRECTLY', + ' */', + '(function () {', + ].join('\n'); + + const footer = [ + '', + ' if (document.readyState === \'loading\') {', + ' document.addEventListener(\'DOMContentLoaded\', init);', + ' } else {', + ' init();', + ' }', + '}());', + '', + ].join('\n'); + + let body = ''; + for (const file of WEBVIEW_FILES) { + const content = fs.readFileSync(path.join(WEBVIEW_SOURCE_DIR, file), 'utf8'); + body += content; + // Ensure each file ends with a newline for clean separation + if (!content.endsWith('\n')) body += '\n'; + } + + fs.writeFileSync(WEBVIEW_OUTPUT, header + '\n' + body + footer); +} + +// ==================== Main Build ==================== + async function main() { + // Generate shared constants from SSOT (remoteConstants.ts → web/shared-constants.js) + generateSharedConstants(); + console.log('Shared constants generated'); + + // Build webview (concatenation, fast) + buildWebview(); + console.log('Webview build complete'); + + // Build extension (esbuild, TypeScript bundling) const ctx = await esbuild.context({ entryPoints: ['src/extension.ts'], bundle: true, @@ -21,7 +182,26 @@ async function main() { if (watch) { await ctx.watch(); - console.log('Watching for changes...'); + console.log('Watching extension for changes...'); + + // Also watch webview source files for changes + const debounceTimers = {}; + for (const file of WEBVIEW_FILES) { + const filePath = path.join(WEBVIEW_SOURCE_DIR, file); + fs.watch(filePath, () => { + // Debounce rebuilds (50ms) + clearTimeout(debounceTimers[file]); + debounceTimers[file] = setTimeout(() => { + try { + buildWebview(); + console.log(`Webview rebuilt (${file} changed)`); + } catch (e) { + console.error('Webview build error:', e.message); + } + }, 50); + }); + } + console.log('Watching webview source for changes...'); } else { await ctx.rebuild(); await ctx.dispose(); diff --git a/tasksync-chat/media/main.css b/tasksync-chat/media/main.css index 7825f30..611b083 100644 --- a/tasksync-chat/media/main.css +++ b/tasksync-chat/media/main.css @@ -1,2654 +1,2772 @@ -/* TaskSync Extension - VS Code Themed Styles */ - -/* Screen reader only */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -:root { - --queue-list-max-height: 120px; - --textarea-max-height: 120px; -} - -html, -body { - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; -} - -body { - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - font-weight: var(--vscode-font-weight); - color: var(--vscode-foreground); - background-color: var(--vscode-sideBar-background); - line-height: 1.5; -} - -* { - box-sizing: border-box; -} - -.main-container { - display: flex; - flex-direction: column; - height: 100%; - padding: 12px; - padding-top: 0; - gap: 0; - overflow: hidden; - position: relative; -} - -.main-container>.chat-container { - margin-bottom: 8px; -} - -/* --- Chat Container --- */ -.chat-container { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - min-height: 0; - position: relative; -} - -/* Thin scrollbar like VS Code sidebar */ -.chat-container::-webkit-scrollbar { - width: 4px; -} - -.chat-container::-webkit-scrollbar-track { - background: transparent; -} - -.chat-container::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 2px; - transition: background-color 0.2s ease; -} - -.chat-container:hover::-webkit-scrollbar-thumb { - background-color: var(--vscode-scrollbarSlider-background); -} - -.chat-container::-webkit-scrollbar-thumb:hover { - background-color: var(--vscode-scrollbarSlider-hoverBackground); -} - -/* Firefox thin scrollbar */ -.chat-container { - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -.chat-container:hover { - scrollbar-color: var(--vscode-scrollbarSlider-background) transparent; -} - -/* --- Welcome Section - Let's Build --- */ -.welcome-section { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 16px 12px; - text-align: center; - flex: 1 1 auto; -} - -.welcome-icon { - color: var(--vscode-foreground); - margin-bottom: 8px; - opacity: 0.9; -} - -.welcome-icon svg { - width: 36px; - height: 36px; -} - -.welcome-logo { - width: 40px; - height: 40px; - object-fit: contain; - /* Invert logo to white for dark themes */ - filter: brightness(0) invert(1); -} - -.welcome-title { - font-size: 20px; - font-weight: 600; - color: var(--vscode-foreground); - margin: 0 0 4px 0; - letter-spacing: -0.5px; -} - -.welcome-subtitle { - font-size: 13px; - color: var(--vscode-descriptionForeground); - margin: 0 0 14px 0; -} - -/* Welcome Cards - Vibe & Spec */ -.welcome-cards { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - width: 100%; - max-width: 500px; - margin-bottom: 12px; -} - -/* Welcome Autopilot info text - centered below cards */ -.welcome-autopilot-info { - font-size: 11px; - color: var(--vscode-descriptionForeground); - margin: 8px 0 0 0; - line-height: 1.5; - text-align: justify; - max-width: 500px; -} - -.welcome-autopilot-info strong { - color: var(--vscode-foreground); - font-weight: 600; -} - -.welcome-card { - padding: 12px; - border-radius: 8px; - text-align: left; - cursor: pointer; - transition: all 0.2s ease; - border: 1px solid transparent; - user-select: none; - position: relative; - z-index: 1; -} - -.welcome-card-vibe { - background-color: var(--vscode-input-background); - border-color: var(--vscode-panel-border); -} - -.welcome-card-vibe:hover { - border-color: var(--vscode-focusBorder); -} - -.welcome-card-vibe.selected { - border-color: var(--vscode-focusBorder); - box-shadow: 0 0 0 2px var(--vscode-focusBorder); - background-color: color-mix(in srgb, var(--vscode-focusBorder) 10%, var(--vscode-input-background)); -} - -.welcome-card-spec { - background-color: var(--vscode-input-background); - border-color: var(--vscode-panel-border); -} - -.welcome-card-spec:hover { - border-color: var(--vscode-focusBorder); -} - -.welcome-card-spec.selected { - border-color: var(--vscode-focusBorder); - box-shadow: 0 0 0 2px var(--vscode-focusBorder); - background-color: color-mix(in srgb, var(--vscode-focusBorder) 10%, var(--vscode-input-background)); -} - -.welcome-card-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 6px; -} - -.welcome-card-header .codicon { - font-size: 14px; - opacity: 0.9; -} - -.welcome-card-title { - font-size: 13px; - font-weight: 600; - color: var(--vscode-foreground); -} - -.welcome-card-desc { - font-size: 11px; - color: var(--vscode-descriptionForeground); - margin: 0; - line-height: 1.4; -} - -/* Tool History Area */ -.tool-history-area { - display: flex; - flex-direction: column; - gap: 2px; - padding: 12px 4px 0 4px; - /* Added top padding to separate from title bar */ - flex-shrink: 0; -} - -/* Tool Call Card - Compact Design */ -.tool-call-card { - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - overflow: hidden; - transition: all 0.2s ease; - margin-bottom: 2px; -} - -.tool-call-card:last-child { - margin-bottom: 0; -} - -/* Card Header - Compact gray bar with chevron and title */ -.tool-call-header { - display: flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - cursor: pointer; - user-select: none; - background-color: rgba(245, 245, 245, 0.10); - min-height: 20px; -} - -.tool-call-header:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.tool-call-chevron { - display: flex; - align-items: center; - justify-content: center; - width: 11px; - height: 6px; - color: var(--vscode-foreground); - transition: transform 0.2s ease; -} - -.tool-call-chevron .codicon { - font-size: 10px; -} - -/* Figma: chevron rotated 180deg when expanded (pointing up), -90deg when collapsed */ -.tool-call-card.expanded .tool-call-chevron { - transform: rotate(180deg); -} - -.tool-call-card:not(.expanded) .tool-call-chevron { - transform: rotate(0deg); -} - -.tool-call-icon { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - color: var(--vscode-foreground); -} - -.tool-call-icon .codicon { - font-size: 12px; -} - -.tool-call-header-wrapper { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 4px; - overflow: hidden; -} - -.tool-call-title { - font-size: 11px; - font-weight: 400; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex-shrink: 1; -} - -.tool-call-remove { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - background: transparent; - border: none; - border-radius: 3px; - color: var(--vscode-descriptionForeground); - cursor: pointer; - opacity: 0; - transition: all 0.15s ease; -} - -.tool-call-header:hover .tool-call-remove { - opacity: 1; -} - -.tool-call-remove:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-errorForeground); -} - -/* Card Body - Compact AI response and user response */ -.tool-call-body { - display: none; - padding: 6px 8px; -} - -.tool-call-card.expanded .tool-call-body { - display: block; -} - -.tool-call-ai-response { - font-size: 11px; - line-height: 1.35; - color: var(--vscode-foreground); - margin-bottom: 6px; - word-wrap: break-word; -} - -.tool-call-ai-response p { - margin: 0 0 4px 0; -} - -.tool-call-ai-response p:last-child { - margin-bottom: 0; -} - -/* Markdown headers in AI response */ -.tool-call-ai-response h1, -.tool-call-ai-response h2, -.tool-call-ai-response h3, -.tool-call-ai-response h4, -.tool-call-ai-response h5, -.tool-call-ai-response h6, -.pending-ai-question h1, -.pending-ai-question h2, -.pending-ai-question h3, -.pending-ai-question h4, -.pending-ai-question h5, -.pending-ai-question h6 { - margin: 12px 0 8px 0; - font-weight: 600; - color: var(--vscode-foreground); - line-height: 1.3; -} - -.tool-call-ai-response h1, -.pending-ai-question h1 { - font-size: 1.5em; -} - -.tool-call-ai-response h2, -.pending-ai-question h2 { - font-size: 1.3em; -} - -.tool-call-ai-response h3, -.pending-ai-question h3 { - font-size: 1.15em; -} - -.tool-call-ai-response h4, -.pending-ai-question h4 { - font-size: 1.05em; -} - -.tool-call-ai-response h5, -.pending-ai-question h5 { - font-size: 1em; -} - -.tool-call-ai-response h6, -.pending-ai-question h6 { - font-size: 0.9em; - color: var(--vscode-descriptionForeground); -} - -/* Lists in AI response */ -.tool-call-ai-response ul, -.tool-call-ai-response ol, -.pending-ai-question ul, -.pending-ai-question ol { - margin: 8px 0; - padding-left: 24px; -} - -.tool-call-ai-response li, -.pending-ai-question li { - margin: 4px 0; -} - -/* Blockquotes in AI response */ -.tool-call-ai-response blockquote, -.pending-ai-question blockquote { - margin: 8px 0; - padding: 8px 12px; - border-left: 3px solid var(--vscode-textBlockQuote-border, var(--vscode-focusBorder)); - background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1)); - color: var(--vscode-descriptionForeground); - font-style: italic; -} - -/* Horizontal rule in AI response */ -.tool-call-ai-response hr, -.pending-ai-question hr { - border: none; - border-top: 1px solid var(--vscode-panel-border); - margin: 12px 0; -} - -/* Markdown tables */ -.tool-call-ai-response .markdown-table, -.pending-ai-question .markdown-table { - width: 100%; - border-collapse: collapse; - margin: 12px 0; - font-size: 12px; -} - -.tool-call-ai-response .markdown-table th, -.pending-ai-question .markdown-table th { - background-color: var(--vscode-editor-selectionBackground, rgba(127, 127, 127, 0.15)); - border: 1px solid var(--vscode-panel-border); - padding: 8px 12px; - text-align: left; - font-weight: 600; - color: var(--vscode-foreground); -} - -.tool-call-ai-response .markdown-table td, -.pending-ai-question .markdown-table td { - border: 1px solid var(--vscode-panel-border); - padding: 8px 12px; - color: var(--vscode-foreground); -} - -.tool-call-ai-response .markdown-table tr:nth-child(even), -.pending-ai-question .markdown-table tr:nth-child(even) { - background-color: var(--vscode-list-hoverBackground, rgba(127, 127, 127, 0.05)); -} - -.tool-call-ai-response .markdown-table tr:hover, -.pending-ai-question .markdown-table tr:hover { - background-color: var(--vscode-list-activeSelectionBackground, rgba(127, 127, 127, 0.1)); -} - -/* Code block styling - uses VS Code theme colors */ -.tool-call-ai-response .code-block, -.pending-ai-question .code-block { - background-color: var(--vscode-textCodeBlock-background, var(--vscode-editor-background)); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - padding: 12px; - margin: 8px 0; - overflow-x: auto; - font-family: var(--vscode-editor-font-family, 'Consolas', 'Courier New', monospace); - font-size: 13px; - line-height: 1.4; -} - -.tool-call-ai-response .code-block code, -.pending-ai-question .code-block code { - background: none; - padding: 0; - font-family: inherit; - font-size: inherit; - white-space: pre; - display: block; - color: var(--vscode-editor-foreground); -} - -/* Inline code styling - uses VS Code theme colors */ -.tool-call-ai-response .inline-code, -.pending-ai-question .inline-code { - background-color: var(--vscode-textCodeBlock-background, var(--vscode-editor-background)); - color: var(--vscode-editor-foreground); - padding: 2px 6px; - border-radius: 4px; - font-family: var(--vscode-editor-font-family, 'Consolas', 'Courier New', monospace); - font-size: 12px; -} - -/* Markdown links in AI response */ -.tool-call-ai-response .markdown-link, -.pending-ai-question .markdown-link { - color: var(--vscode-textLink-foreground); - text-decoration: underline; - text-underline-offset: 2px; - cursor: pointer; -} - -.tool-call-ai-response .markdown-link:hover, -.pending-ai-question .markdown-link:hover { - color: var(--vscode-textLink-activeForeground); -} - -/* Bold and italic */ -.tool-call-ai-response strong { - font-weight: 600; -} - -.tool-call-ai-response em { - font-style: italic; -} - -/* User response section - compact with separator */ -.tool-call-user-section { - border-top: 1px solid var(--vscode-panel-border); - padding-top: 6px; - margin-top: 2px; -} - -.tool-call-user-response { - font-size: 11px; - line-height: 1.35; - color: var(--vscode-foreground); -} - -.tool-call-badge { - font-size: 9px; - padding: 1px 4px; - border-radius: 3px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - margin-left: 6px; -} - -.tool-call-badge.queue { - background-color: var(--vscode-textLink-foreground, #3794ff); - color: #ffffff; -} - -/* Pending AI Question - Plain text bubble (no border) */ -.pending-ai-question { - padding: 10px 12px; - font-size: 13px; - line-height: 1.4; - color: var(--vscode-foreground); - word-wrap: break-word; -} - -/* Working State Indicator - Animated dots */ -.working-indicator { - padding: 12px 4px; - /* Match tool-history-area padding */ - font-size: 13px; - color: var(--vscode-descriptionForeground); - line-height: 1.4; - display: flex; - align-items: center; - justify-content: flex-start; - /* Ensure left alignment */ - gap: 8px; -} - -.working-indicator::before { - content: ''; - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--vscode-progressBar-background, #0078d4); - animation: working-pulse 1.4s ease-in-out infinite; -} - -@keyframes working-pulse { - - 0%, - 100% { - opacity: 0.4; - transform: scale(0.8); - } - - 50% { - opacity: 1; - transform: scale(1); - } -} - -/* New Session Prompt - shown after session terminated */ -.new-session-prompt { - padding: 12px 4px; - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; - color: var(--vscode-descriptionForeground); -} - -.new-session-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 12px; - border: none; - border-radius: 4px; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - font-size: 12px; - cursor: pointer; - white-space: nowrap; -} - -.new-session-btn:hover { - background: var(--vscode-button-hoverBackground); -} - -/* Mermaid diagram container */ -.mermaid-container { - margin: 8px 0; - padding: 12px; - background-color: var(--vscode-textCodeBlock-background, var(--vscode-editor-background)); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - overflow-x: auto; -} - -.mermaid-container .mermaid { - display: flex; - justify-content: center; -} - -.mermaid-container .mermaid svg { - max-width: 100%; - height: auto; -} - -.mermaid-container.error .mermaid { - display: block; -} - -/* Pending Message - Shows as plain text (no border) */ -.pending-message { - margin: 8px 4px; - background-color: transparent; - border: none; - border-radius: 0; - overflow: hidden; - flex-shrink: 0; - text-align: left; - /* Ensure left alignment */ -} - -/* --- Chips Container (inside input) --- */ -.chips-container { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 10px 12px 0 12px; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 4px; - font-size: 12px; - max-width: 200px; -} - -.chip-icon { - display: flex; - align-items: center; - flex-shrink: 0; -} - -.chip-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chip-remove { - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - opacity: 0.7; - transition: opacity 0.15s ease; -} - -.chip-remove:hover { - opacity: 1; -} - -/* --- Input Wrapper (Queue + Input combined) --- */ -.input-wrapper { - display: flex; - flex-direction: column; - flex-shrink: 0; - border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); - border-radius: 10px; - background-color: var(--vscode-editor-background); - overflow: hidden; - /* Must be hidden to preserve border-radius on corners */ - position: relative; -} - -.input-wrapper:focus-within { - border-color: var(--vscode-focusBorder); -} - -/* --- Queue Section - Integrated Design --- */ -.queue-section { - background-color: var(--vscode-editor-background); - border: none; - border-bottom: 1px solid var(--vscode-panel-border); - border-radius: 0; - flex-shrink: 0; - overflow: hidden; - margin: 0; -} - -.queue-section.hidden { - display: none !important; - border-bottom: none; -} - -.queue-section.collapsed .queue-list { - display: none; -} - -.queue-header { - padding: 6px 10px; - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 500; - cursor: pointer; - user-select: none; - color: var(--vscode-foreground); - transition: background-color 0.15s ease; -} - -.queue-header:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.queue-header .accordion-icon { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - transition: transform 0.2s ease; -} - -.queue-section.collapsed .accordion-icon { - transform: rotate(-90deg); -} - -.queue-header-title { - flex: 1; -} - -.queue-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 10px; - font-size: 11px; - font-weight: 500; -} - -.queue-list { - max-height: var(--queue-list-max-height); - overflow-y: auto; - border-top: 1px solid var(--vscode-panel-border); -} - -.queue-item { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 6px 10px; - border-bottom: 1px solid var(--vscode-panel-border); - font-size: 12px; - line-height: 1.3; - cursor: grab; - transition: background-color 0.15s ease; -} - -.queue-item:last-child { - border-bottom: none; -} - -.queue-item:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.queue-item:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: -2px; -} - -.queue-item.dragging { - opacity: 0.5; - cursor: grabbing; -} - -.queue-item.drag-over { - background-color: var(--vscode-list-activeSelectionBackground); -} - -.queue-item .bullet { - width: 8px; - height: 8px; - min-width: 8px; - border-radius: 50%; - margin-top: 5px; - flex-shrink: 0; -} - -.queue-item .bullet.active { - background-color: var(--vscode-focusBorder); -} - -.queue-item .bullet.pending { - border: 1.5px solid var(--vscode-descriptionForeground); - background-color: transparent; -} - -.queue-item .text { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--vscode-foreground); -} - -/* Attachment badge for queue items with images/files */ -.queue-item-attachment-badge { - display: flex; - align-items: center; - justify-content: center; - padding: 2px 4px; - margin-left: 4px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 4px; - flex-shrink: 0; -} - -.queue-item-attachment-badge .codicon { - font-size: 12px; -} - -.queue-item-actions { - display: flex; - align-items: center; - gap: 2px; - opacity: 0; - transition: opacity 0.15s ease; -} - -.queue-item:hover .queue-item-actions, -.queue-item:focus .queue-item-actions { - opacity: 1; -} - -.queue-item .edit-btn, -.queue-item .remove-btn { - background: transparent; - border: none; - color: var(--vscode-descriptionForeground); - cursor: pointer; - padding: 2px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s ease; -} - -.queue-item .edit-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.queue-item .remove-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-errorForeground); -} - -/* Old inline edit input styling - kept for fallback */ -.queue-item .edit-input { - flex: 1; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-focusBorder); - border-radius: 4px; - color: var(--vscode-input-foreground); - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - padding: 4px 8px; - outline: none; - width: 100%; -} - -.queue-item .edit-input:focus { - border-color: var(--vscode-focusBorder); -} - -.queue-empty { - padding: 16px 12px; - text-align: center; - font-size: 12px; - color: var(--vscode-descriptionForeground); - font-style: italic; -} - -/* --- Input Area Container (wrapper for dropdown + input-wrapper) --- */ -.input-area-container { - position: relative; - flex-shrink: 0; - overflow: visible; - /* Allow autocomplete dropdown to overflow */ -} - -/* --- Autocomplete Dropdown --- */ -.autocomplete-dropdown { - position: absolute; - bottom: calc(100% + 4px); - /* Position above the input wrapper */ - left: 0; - right: 0; - max-height: 200px; - background-color: var(--vscode-editorWidget-background); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); - border-radius: 8px; - box-shadow: 0 4px 12px var(--vscode-widget-shadow); - overflow: hidden; - z-index: 1000; -} - -.autocomplete-list { - max-height: 180px; - overflow-y: auto; -} - -.autocomplete-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - cursor: pointer; - transition: background-color 0.1s ease; -} - -.autocomplete-item:hover, -.autocomplete-item.selected { - background-color: var(--vscode-list-hoverBackground); -} - -.autocomplete-item-icon { - display: flex; - align-items: center; - color: var(--vscode-descriptionForeground); -} - -.autocomplete-item-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} - -.autocomplete-item-name { - font-size: 13px; - color: var(--vscode-foreground); -} - -.autocomplete-item-path { - font-size: 11px; - color: var(--vscode-descriptionForeground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.autocomplete-empty { - padding: 12px; - text-align: center; - font-size: 12px; - color: var(--vscode-descriptionForeground); -} - -/* --- Context Dropdown --- */ -.context-dropdown { - position: absolute; - bottom: calc(100% + 4px); - /* Position above the input wrapper */ - left: 0; - right: 0; - max-height: 200px; - background-color: var(--vscode-editorWidget-background); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); - border-radius: 8px; - box-shadow: 0 4px 12px var(--vscode-widget-shadow); - overflow: hidden; - z-index: 1000; -} - -.context-list { - max-height: 180px; - overflow-y: auto; -} - -.context-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - cursor: pointer; - transition: background-color 0.1s ease; -} - -.context-item:hover, -.context-item.selected { - background-color: var(--vscode-list-hoverBackground); -} - -.context-item-icon { - display: flex; - align-items: center; - color: var(--vscode-descriptionForeground); -} - -.context-item-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} - -.context-item-label { - font-size: 13px; - color: var(--vscode-foreground); - display: flex; - align-items: center; - gap: 6px; -} - -.context-item-desc { - font-size: 11px; - color: var(--vscode-descriptionForeground); - font-weight: 400; -} - -.context-item-detail { - font-size: 11px; - color: var(--vscode-descriptionForeground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - opacity: 0.8; -} - -.context-empty { - padding: 12px; - text-align: center; - font-size: 12px; - color: var(--vscode-descriptionForeground); -} - -/* --- Input Container --- */ -.input-container { - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-input-border, transparent); - border-radius: 8px; - /* Parent wrapper handles border-radius */ - display: flex; - flex-direction: column; - flex-shrink: 0; - overflow: visible; - position: relative; - z-index: 10; -} - -/* Input row with textarea and send button */ -.input-row { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 10px 0 10px; -} - -/* Wrapper for input highlighter and textarea */ -.input-highlighter-wrapper { - flex: 1; - position: relative; - min-height: 24px; -} - -/* Highlighting overlay - mirrors textarea content and shows colors */ -.input-highlighter { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - padding: 4px 0; - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - line-height: 1.4; - color: transparent; - /* Text invisible - only background highlights show through */ - white-space: pre-wrap; - word-wrap: break-word; - overflow: hidden; - pointer-events: none; - z-index: 1; -} - -/* Slash command highlight in overlay - background only */ -.input-highlighter .slash-highlight { - color: transparent; - /* Text is invisible */ - background-color: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 20%, transparent); - border-radius: 3px; - padding: 1px 2px; - margin: 0 -2px; -} - -/* Hash/file reference highlight in overlay - background only */ -.input-highlighter .hash-highlight { - color: transparent; - /* Text is invisible */ - background-color: color-mix(in srgb, var(--vscode-symbolIcon-fileForeground, #e8ab53) 20%, transparent); - border-radius: 3px; - padding: 1px 2px; - margin: 0 -2px; -} - -textarea#chat-input { - flex: 1; - position: relative; - z-index: 2; - border: none; - resize: none; - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - padding: 4px 0; - background: transparent; - outline: none; - min-height: 24px; - max-height: var(--textarea-max-height); - color: var(--vscode-input-foreground); - /* Normal visible text */ - line-height: 1.4; - overflow-y: auto; - scrollbar-width: none; - /* Firefox */ - -ms-overflow-style: none; - /* IE/Edge */ - width: 100%; -} - -textarea#chat-input::-webkit-scrollbar { - display: none; - /* Chrome/Safari */ -} - -textarea#chat-input::placeholder { - color: var(--vscode-input-placeholderForeground); - opacity: 1; - font-size: 11px; - /* Ensure placeholder is visible even with transparent text color */ -} - -/* Send Button - positioned at top right of input row */ -button#send-btn { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 4px; - border: none; - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); - cursor: pointer; - transition: all 0.15s ease; - flex-shrink: 0; -} - -button#send-btn:hover { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-foreground); -} - -/* Send button active state - when there's text in input */ -button#send-btn.has-text { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -button#send-btn.has-text:hover { - background-color: var(--vscode-button-hoverBackground); -} - -button#send-btn .codicon { - font-size: 12px; -} - -.actions-bar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 10px; - background: transparent; - position: relative; - overflow: visible; -} - -.autopilot-label { - font-size: 11px; - color: var(--vscode-descriptionForeground); - font-weight: 500; - user-select: none; -} - -.actions-left { - display: flex; - align-items: center; - gap: 4px; -} - -.actions-right { - display: flex; - align-items: center; - gap: 6px; -} - -.icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transition: all 0.15s ease; -} - -.icon-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -/* Mode Selector */ -.mode-selector { - position: relative; -} - -.mode-btn { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - font-size: 11px; - cursor: pointer; - transition: all 0.15s ease; -} - -.mode-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.mode-btn .codicon { - font-size: 10px; -} - -/* Mode Dropdown */ -.mode-dropdown { - position: fixed; - min-width: 160px; - background-color: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - z-index: 1000; - opacity: 0; - visibility: hidden; - transform: translateY(4px); - transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; -} - -.mode-dropdown.visible { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.mode-dropdown.hidden { - display: none; -} - -.mode-option { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - cursor: pointer; - transition: background-color 0.1s ease; -} - -.mode-option .codicon { - font-size: 12px; - opacity: 0.9; -} - -.mode-option:first-child { - border-radius: 5px 5px 0 0; -} - -.mode-option:last-child { - border-radius: 0 0 5px 5px; -} - -.mode-option:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.mode-option.selected { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); -} - -/* Send button disabled state */ -button#send-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.hidden { - display: none !important; -} - -/* Responsive adjustments */ -@media (max-width: 400px) { - .welcome-cards { - grid-template-columns: 1fr; - } - - .welcome-title { - font-size: 20px; - } -} - - -/* History Modal Overlay */ -.history-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - padding: 16px; -} - -.history-modal-overlay.hidden { - display: none; -} - -/* History Modal */ -.history-modal { - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 10px; - width: 100%; - max-width: 320px; - max-height: 50vh; - display: flex; - flex-direction: column; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); -} - -.history-modal-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid var(--vscode-panel-border); - background-color: var(--vscode-input-background); -} - -.history-modal-title { - font-size: 13px; - font-weight: 600; - color: var(--vscode-foreground); -} - -.history-modal-info { - flex: 1; - font-size: 10px; - color: var(--vscode-descriptionForeground); - text-align: left; - padding-left: 6px; -} - -.history-modal-clear-btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transition: all 0.15s ease; -} - -.history-modal-clear-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-errorForeground); -} - -.history-modal-clear-btn.hidden { - display: none; -} - -.history-modal-close-btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transition: all 0.15s ease; -} - -.history-modal-close-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.history-modal-list { - flex: 1; - overflow-y: auto; - padding: 8px; - scrollbar-width: none; - /* Firefox */ - -ms-overflow-style: none; - /* IE/Edge */ -} - -.history-modal-list::-webkit-scrollbar { - display: none; - /* Chrome/Safari/Opera */ -} - -.history-modal-empty { - padding: 24px 12px; - text-align: center; - font-size: 12px; - color: var(--vscode-descriptionForeground); -} - -/* History items list - simple flat list */ -.history-items-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -/* History grouped by sessions */ -.history-session-group { - margin-bottom: 16px; -} - -.history-session-group:last-child { - margin-bottom: 0; -} - -.history-session-header { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background: var(--vscode-sideBar-background); - border-radius: 4px; - margin-bottom: 6px; - border-left: 2px solid var(--vscode-focusBorder); - cursor: pointer; - transition: background-color 0.1s ease; -} - -.history-session-header:hover { - background: var(--vscode-list-hoverBackground); -} - -.history-session-chevron { - display: flex; - align-items: center; - transition: transform 0.2s ease; -} - -.history-session-chevron .codicon { - font-size: 10px; - color: var(--vscode-descriptionForeground); -} - -.history-session-group.collapsed .history-session-chevron { - transform: rotate(-90deg); -} - -.history-session-group.collapsed .history-session-items { - display: none; -} - -.history-session-group.collapsed .history-session-header { - margin-bottom: 0; -} - -.history-session-icon { - display: flex; - align-items: center; - color: var(--vscode-focusBorder); -} - -.history-session-icon .codicon { - font-size: 12px; -} - -.history-session-name { - font-weight: 600; - font-size: 11px; - color: var(--vscode-foreground); - flex: 1; -} - -.history-session-count { - font-size: 10px; - color: var(--vscode-descriptionForeground); - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - padding: 1px 6px; - border-radius: 8px; -} - -.history-session-items { - display: flex; - flex-direction: column; - gap: 4px; - padding-left: 10px; -} - -/* ===== EDIT MODE STYLES ===== */ - -/* Edit mode container for buttons in actions bar */ -.edit-actions-container { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 8px; -} - -.edit-actions-container.hidden { - display: none; -} - -.edit-mode-label { - font-size: 11px; - color: var(--vscode-descriptionForeground); - font-style: italic; -} - -.edit-btn-group { - display: flex; - align-items: center; - gap: 4px; -} - -/* Edit mode buttons */ -.edit-cancel-btn, -.edit-confirm-btn { - width: 28px; - height: 28px; - border-radius: 4px; - transition: all 0.15s ease; -} - -.edit-cancel-btn { - color: var(--vscode-descriptionForeground); -} - -.edit-cancel-btn:hover { - background-color: var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1)); - color: var(--vscode-errorForeground); -} - -.edit-confirm-btn { - color: var(--vscode-descriptionForeground); -} - -.edit-confirm-btn:hover { - background-color: var(--vscode-inputValidation-infoBackground, rgba(0, 150, 0, 0.1)); - color: var(--vscode-testing-iconPassed, #4caf50); -} - -/* Input container edit mode styling */ -.input-container.edit-mode { - border-color: var(--vscode-focusBorder); - background-color: var(--vscode-input-background); -} - -/* Queue item editing state */ -.queue-item.editing { - background-color: var(--vscode-editor-selectionBackground); - border-left: 2px solid var(--vscode-focusBorder); - opacity: 0.7; -} - -.queue-item.editing .text { - font-style: italic; -} - -.queue-item.editing .text::after { - content: ' (editing...)'; - color: var(--vscode-descriptionForeground); - font-size: 10px; -} - -.queue-item.editing .queue-item-actions { - display: none; -} - -/* ===== APPROVAL BAR STYLES ===== */ - -/* Approval bar - integrated at top of input-wrapper */ -.approval-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background-color: var(--vscode-inputValidation-infoBackground, rgba(100, 100, 200, 0.15)); - border-bottom: 1px solid var(--vscode-panel-border); - animation: approval-fade-in 0.15s ease-out; -} - -.approval-bar.hidden { - display: none; -} - -@keyframes approval-fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -/* Label on left side */ -.approval-label { - font-size: 12px; - color: var(--vscode-foreground); - font-weight: 400; -} - -/* Buttons container on right */ -.approval-buttons { - display: flex; - align-items: center; - gap: 8px; -} - -/* Approval buttons - text style */ -.approval-btn { - padding: 4px 10px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - font-family: var(--vscode-font-family); - border: none; -} - -.approval-btn:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 1px; -} - -/* Reject button - text only, no background */ -.approval-reject-btn { - background: transparent; - color: var(--vscode-foreground); -} - -.approval-reject-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-errorForeground); -} - -/* Accept button - primary style */ -.approval-accept-btn { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -.approval-accept-btn:hover { - background-color: var(--vscode-button-hoverBackground); -} - -.approval-accept-btn:active, -.approval-reject-btn:active { - transform: scale(0.98); -} - -/* ===== CHOICES BAR STYLES ===== */ - -/* Choices bar - appears for multi-choice questions */ -.choices-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background-color: var(--vscode-inputValidation-infoBackground, rgba(100, 100, 200, 0.15)); - border-bottom: 1px solid var(--vscode-panel-border); - animation: choices-fade-in 0.15s ease-out; - gap: 12px; -} - -.choices-bar.hidden { - display: none; -} - -@keyframes choices-fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -/* Label on left side */ -.choices-label { - font-size: 12px; - color: var(--vscode-foreground); - font-weight: 400; - flex-shrink: 0; -} - -/* Buttons container - flex wrap for multiple choices */ -.choices-buttons { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - justify-content: flex-end; - flex: 1; -} - -/* Choice button - compact numbered style */ -.choice-btn { - padding: 4px 12px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - font-family: var(--vscode-font-family); - border: 1px solid var(--vscode-button-border, var(--vscode-panel-border)); - background-color: var(--vscode-button-secondaryBackground, var(--vscode-input-background)); - color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); - min-width: 28px; - text-align: center; -} - -.choice-btn:hover { - background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground)); - border-color: var(--vscode-focusBorder); -} - -.choice-btn:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 1px; -} - -.choice-btn:active { - transform: scale(0.98); -} - -/* Selected choice button */ -.choice-btn.selected { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border-color: var(--vscode-button-background); -} - -.choice-btn.selected:hover { - background-color: var(--vscode-button-hoverBackground); - border-color: var(--vscode-button-hoverBackground); -} - -/* Actions container for All/Send buttons */ -.choices-actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -/* Action buttons (All, Send) */ -.choices-action-btn { - padding: 4px 10px; - border-radius: 4px; - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - font-family: var(--vscode-font-family); - border: 1px solid var(--vscode-panel-border); - background: transparent; - color: var(--vscode-foreground); - white-space: nowrap; -} - -.choices-action-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.choices-action-btn:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 1px; -} - -.choices-action-btn:active { - transform: scale(0.98); -} - -.choices-action-btn:disabled { - opacity: 0.5; - cursor: default; -} - -.choices-action-btn:disabled:hover { - background: transparent; -} - -/* Send button - primary style when enabled */ -.choices-send-btn:not(:disabled) { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border-color: var(--vscode-button-background); -} - -.choices-send-btn:not(:disabled):hover { - background-color: var(--vscode-button-hoverBackground); -} - -/* ===== SLASH COMMAND DROPDOWN STYLES ===== */ - -.slash-dropdown { - position: absolute; - bottom: calc(100% + 4px); - left: 0; - right: 0; - max-height: 200px; - background-color: var(--vscode-editorWidget-background); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); - border-radius: 8px; - box-shadow: 0 4px 12px var(--vscode-widget-shadow); - overflow: hidden; - z-index: 1001; -} - -.slash-dropdown.hidden { - display: none; -} - -.slash-list { - max-height: 180px; - overflow-y: auto; -} - -.slash-item { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - cursor: pointer; - transition: background-color 0.1s ease; - border-bottom: 1px solid var(--vscode-panel-border); -} - -.slash-item:last-child { - border-bottom: none; -} - -.slash-item:hover, -.slash-item.selected { - background-color: var(--vscode-list-hoverBackground); -} - -.slash-item-icon { - display: flex; - align-items: center; - color: var(--vscode-textLink-foreground, #3794ff); - padding-top: 2px; -} - -.slash-item-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.slash-item-name { - font-size: 13px; - font-weight: 500; - color: var(--vscode-textLink-foreground, #3794ff); -} - -.slash-item-preview { - font-size: 11px; - color: var(--vscode-descriptionForeground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 250px; -} - -/* Tooltip for slash dropdown items - show full prompt on hover */ -.slash-item { - position: relative; -} - -.slash-item::after { - content: attr(data-tooltip); - position: absolute; - left: calc(100% + 8px); - top: 0; - background-color: var(--vscode-editorWidget-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - padding: 10px 12px; - font-size: 12px; - color: var(--vscode-foreground); - white-space: pre-wrap; - word-break: break-word; - max-height: 200px; - max-width: 300px; - overflow-y: auto; - box-shadow: 0 4px 12px var(--vscode-widget-shadow); - z-index: 200; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; - pointer-events: none; -} - -.slash-item:hover::after { - opacity: 1; - visibility: visible; -} - -.slash-empty { - padding: 16px 12px; - text-align: center; - font-size: 12px; - color: var(--vscode-descriptionForeground); -} - -/* Slash tag chip - shown in input */ -.slash-tag { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - background-color: var(--vscode-textLink-foreground, #3794ff); - color: #ffffff; - border-radius: 4px; - font-size: 12px; - cursor: pointer; - position: relative; -} - -.slash-tag:hover { - opacity: 0.9; -} - -/* Slash tag tooltip on hover */ -.slash-tag-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - background-color: var(--vscode-editorWidget-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - padding: 8px 12px; - font-size: 12px; - color: var(--vscode-foreground); - white-space: pre-wrap; - max-width: 300px; - max-height: 150px; - overflow-y: auto; - box-shadow: 0 4px 12px var(--vscode-widget-shadow); - z-index: 1002; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: opacity 0.15s ease, visibility 0.15s ease; -} - -.slash-tag:hover .slash-tag-tooltip { - opacity: 1; - visibility: visible; -} - -/* ===== SETTINGS MODAL STYLES ===== */ - -.settings-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - padding: 16px; -} - -.settings-modal-overlay.hidden { - display: none; -} - -.settings-modal { - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 10px; - width: 100%; - max-width: 320px; - max-height: 50vh; - display: flex; - flex-direction: column; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); -} - -.settings-modal-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid var(--vscode-panel-border); - background-color: var(--vscode-input-background); -} - -.settings-modal-title { - flex: 1; - font-size: 13px; - font-weight: 600; - color: var(--vscode-foreground); -} - -.settings-modal-header-buttons { - display: flex; - align-items: center; - gap: 4px; -} - -.settings-modal-header-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transition: all 0.15s ease; -} - -.settings-modal-header-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.settings-modal-content { - flex: 1; - overflow-y: auto; - padding: 10px; - scrollbar-width: none; - /* Firefox */ - -ms-overflow-style: none; - /* IE/Edge */ -} - -.settings-modal-content::-webkit-scrollbar { - display: none; - /* Chrome/Safari/Opera */ -} - -/* Settings Section */ -.settings-section { - margin-bottom: 10px; -} - -.settings-section:last-child { - margin-bottom: 0; -} - -.settings-section-title { - font-size: 11px; - font-weight: 600; - color: var(--vscode-foreground); - display: flex; - align-items: center; - gap: 6px; -} - -/* Info icon tooltip */ -.settings-info-icon { - display: inline-flex; - align-items: center; - margin-left: 4px; - cursor: help; -} - -.settings-info-icon .codicon { - font-size: 12px; - opacity: 0.6; - transition: opacity 0.15s ease; -} - -.settings-info-icon:hover .codicon { - opacity: 1; -} - -/* Section header with toggle inline */ -.settings-section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0; -} - -.settings-section-header .settings-section-title { - margin-bottom: 0; -} - -.settings-section>.form-row { - margin-top: 8px; -} - -/* Toggle Switch */ -.toggle-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 0; -} - -.toggle-label { - font-size: 12px; - color: var(--vscode-foreground); -} - -.toggle-switch { - position: relative; - width: 36px; - height: 18px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 9px; - cursor: pointer; - transition: all 0.2s ease; -} - -.toggle-switch.active { - background-color: var(--vscode-textLink-foreground, #3794ff); - border-color: var(--vscode-textLink-foreground, #3794ff); -} - -.toggle-switch::after { - content: ''; - position: absolute; - top: 2px; - left: 2px; - width: 12px; - height: 12px; - background-color: var(--vscode-foreground); - border-radius: 50%; - transition: transform 0.2s ease; -} - -.toggle-switch.active::after { - transform: translateX(18px); - background-color: #ffffff; -} - -/* Reusable Prompts List */ -.prompts-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: 6px; -} - -.prompt-item { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 6px 10px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - transition: border-color 0.15s ease; - position: relative; -} - -/* Compact mode - single line with name only */ -.prompt-item.compact { - align-items: center; - padding: 5px 10px; -} - -.prompt-item.compact .prompt-item-content { - display: flex; - align-items: center; -} - -.prompt-item.compact .prompt-item-name { - margin-bottom: 0; -} - -.prompt-item:hover { - border-color: var(--vscode-focusBorder); -} - -.prompt-item-content { - flex: 1; - min-width: 0; -} - -.prompt-item-name { - font-size: 12px; - font-weight: 500; - color: var(--vscode-textLink-foreground, #3794ff); - margin-bottom: 2px; -} - -.prompt-item-text { - font-size: 11px; - color: var(--vscode-descriptionForeground); - white-space: pre-wrap; - word-break: break-word; - max-height: 40px; - overflow: hidden; -} - -.prompt-item-actions { - display: flex; - gap: 4px; - flex-shrink: 0; - opacity: 0; - transition: opacity 0.15s ease; -} - -.prompt-item:hover .prompt-item-actions { - opacity: 1; -} - -.prompt-item-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transition: all 0.15s ease; -} - -.prompt-item-btn:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.prompt-item-btn.delete:hover { - color: var(--vscode-errorForeground); -} - -/* Prompt item tooltip hidden - using native title instead */ -.prompt-item.compact::after { - display: none; -} - -/* ========== Autopilot Prompts List ========== */ -.autopilot-prompts-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: 6px; - min-height: 24px; -} - -.empty-prompts-hint { - font-size: 11px; - color: var(--vscode-descriptionForeground); - padding: 8px; - text-align: center; - font-style: italic; -} - -.autopilot-prompt-item { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - transition: border-color 0.15s ease, transform 0.15s ease, opacity 0.15s ease; - cursor: grab; -} - -.autopilot-prompt-item:hover { - border-color: var(--vscode-focusBorder); -} - -.autopilot-prompt-item.dragging { - opacity: 0.5; - cursor: grabbing; -} - -.autopilot-prompt-item.drag-over-top { - border-top: 2px solid var(--vscode-focusBorder); -} - -.autopilot-prompt-item.drag-over-bottom { - border-bottom: 2px solid var(--vscode-focusBorder); -} - -.autopilot-prompt-drag-handle { - color: var(--vscode-descriptionForeground); - cursor: grab; - flex-shrink: 0; -} - -.autopilot-prompt-number { - font-size: 11px; - font-weight: 500; - color: var(--vscode-descriptionForeground); - flex-shrink: 0; - margin-right: -2px; -} - -.autopilot-prompt-text { - font-size: 12px; - color: var(--vscode-foreground); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.autopilot-prompt-actions { - display: flex; - gap: 4px; - flex-shrink: 0; - opacity: 0; - transition: opacity 0.15s ease; -} - -.autopilot-prompt-item:hover .autopilot-prompt-actions { - opacity: 1; -} - -/* Add Autopilot Prompt Form */ -.add-autopilot-prompt-form { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 8px; - padding: 10px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; -} - -/* ========== End Autopilot Prompts List ========== */ - -/* Add Prompt Form */ -.add-prompt-form { - display: flex; - flex-direction: column; - gap: 8px; - padding: 14px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - margin-top: 12px; -} - -.add-prompt-form.hidden { - display: none; -} - -.form-row { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 12px; -} - -.form-label { - font-size: 11px; - font-weight: 500; - color: var(--vscode-descriptionForeground); - text-transform: uppercase; - margin-top: 4px; -} - -.form-input { - padding: 6px 10px; - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - background-color: var(--vscode-editor-background); - color: var(--vscode-input-foreground); - font-family: var(--vscode-font-family); - font-size: 13px; -} - -.form-input:focus { - outline: none; - border-color: var(--vscode-focusBorder); -} - -.form-textarea { - resize: vertical; - min-height: 60px; - max-height: 150px; -} - -/* Select dropdown styling */ -.form-select { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23888' d='M4.5 5.5L8 9l3.5-3.5z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 12px; - padding-right: 28px; - cursor: pointer; - width: 100%; -} - -.form-select:focus { - outline: none; - border-color: var(--vscode-focusBorder); -} - -/* Number input styling */ -.form-input[type="number"] { - width: 100%; -} - -/* Small inline inputs for human-like delay settings */ -.human-delay-range { - display: flex; - flex-direction: row !important; - align-items: center; - gap: 4px; - margin-bottom: 0; -} - -.form-label-inline { - font-size: 11px; - color: var(--vscode-descriptionForeground); - white-space: nowrap; -} - -.form-input-small { - width: 60px !important; - min-width: 60px; - padding: 4px 8px; - font-size: 12px; -} - -.form-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 4px; -} - -.form-btn { - padding: 6px 12px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - border: none; - font-family: var(--vscode-font-family); -} - -.form-btn-cancel { - background: transparent; - color: var(--vscode-foreground); -} - -.form-btn-cancel:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.form-btn-save { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -.form-btn-save:hover { - background-color: var(--vscode-button-hoverBackground); -} - -/* Add Prompt Button - inline (next to title) */ -.add-prompt-btn-inline { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transition: all 0.15s ease; -} - -.add-prompt-btn-inline:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-textLink-foreground, #3794ff); -} - -.add-prompt-btn-inline .codicon { - font-size: 14px; -} - -/* Empty state for prompts */ -.prompts-empty { - padding: 16px; - text-align: center; - color: var(--vscode-descriptionForeground); - font-size: 11px; - font-style: italic; +/* TaskSync Extension - VS Code Themed Styles */ + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +:root { + --queue-list-max-height: 120px; + --textarea-max-height: 120px; +} + +html, +body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body { + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + font-weight: var(--vscode-font-weight); + color: var(--vscode-foreground); + background-color: var(--vscode-sideBar-background); + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +.main-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 12px; + padding-top: 0; + gap: 0; + overflow: hidden; + position: relative; +} + +.main-container>.chat-container { + margin-bottom: 8px; +} + +/* --- Chat Container --- */ +.chat-container { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; +} + +/* Thin scrollbar like VS Code sidebar */ +.chat-container::-webkit-scrollbar { + width: 4px; +} + +.chat-container::-webkit-scrollbar-track { + background: transparent; +} + +.chat-container::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 2px; + transition: background-color 0.2s ease; +} + +.chat-container:hover::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); +} + +.chat-container::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); +} + +/* Firefox thin scrollbar */ +.chat-container { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.chat-container:hover { + scrollbar-color: var(--vscode-scrollbarSlider-background) transparent; +} + +/* --- Welcome Section - Let's Build --- */ +.welcome-section { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px 12px; + text-align: center; + flex: 1 1 auto; +} + +.welcome-icon { + color: var(--vscode-foreground); + margin-bottom: 8px; + opacity: 0.9; +} + +.welcome-icon svg { + width: 36px; + height: 36px; +} + +.welcome-logo { + width: 40px; + height: 40px; + object-fit: contain; + /* Invert logo to white for dark themes */ + filter: brightness(0) invert(1); +} + +.welcome-title { + font-size: 20px; + font-weight: 600; + color: var(--vscode-foreground); + margin: 0 0 4px 0; + letter-spacing: -0.5px; +} + +.welcome-subtitle { + font-size: 13px; + color: var(--vscode-descriptionForeground); + margin: 0 0 14px 0; +} + +/* Welcome Cards - Vibe & Spec */ +.welcome-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + width: 100%; + max-width: 500px; + margin-bottom: 12px; +} + +/* Welcome Autopilot info text - centered below cards */ +.welcome-autopilot-info { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin: 8px 0 0 0; + line-height: 1.5; + text-align: justify; + max-width: 500px; +} + +.welcome-autopilot-info strong { + color: var(--vscode-foreground); + font-weight: 600; +} + +.welcome-card { + padding: 12px; + border-radius: 8px; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; + user-select: none; + position: relative; + z-index: 1; +} + +.welcome-card-vibe { + background-color: var(--vscode-input-background); + border-color: var(--vscode-panel-border); +} + +.welcome-card-vibe:hover { + border-color: var(--vscode-focusBorder); +} + +.welcome-card-vibe.selected { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 2px var(--vscode-focusBorder); + background-color: color-mix(in srgb, var(--vscode-focusBorder) 10%, var(--vscode-input-background)); +} + +.welcome-card-spec { + background-color: var(--vscode-input-background); + border-color: var(--vscode-panel-border); +} + +.welcome-card-spec:hover { + border-color: var(--vscode-focusBorder); +} + +.welcome-card-spec.selected { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 2px var(--vscode-focusBorder); + background-color: color-mix(in srgb, var(--vscode-focusBorder) 10%, var(--vscode-input-background)); +} + +.welcome-card-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.welcome-card-header .codicon { + font-size: 14px; + opacity: 0.9; +} + +.welcome-card-title { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.welcome-card-desc { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin: 0; + line-height: 1.4; +} + +/* Tool History Area */ +.tool-history-area { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 4px 0 4px; + /* Added top padding to separate from title bar */ + flex-shrink: 0; +} + +/* ————— Remote Chat Messages Area ————— */ +.chat-stream-area { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 8px 4px 8px; + flex-shrink: 0; +} + +.chat-stream-area.hidden { + display: none; +} + +.chat-stream-msg { + padding: 10px 14px; + border-radius: 10px; + max-width: 90%; + word-wrap: break-word; + white-space: pre-wrap; + line-height: 1.5; + font-size: 13px; +} + +.chat-stream-msg.user { + align-self: flex-end; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-bottom-right-radius: 3px; +} + +/* Tool Call Card - Compact Design */ +.tool-call-card { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + overflow: hidden; + transition: all 0.2s ease; + margin-bottom: 2px; +} + +.tool-call-card:last-child { + margin-bottom: 0; +} + +/* Card Header - Compact gray bar with chevron and title */ +.tool-call-header { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + cursor: pointer; + user-select: none; + background-color: rgba(245, 245, 245, 0.10); + min-height: 20px; +} + +.tool-call-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.tool-call-chevron { + display: flex; + align-items: center; + justify-content: center; + width: 11px; + height: 6px; + color: var(--vscode-foreground); + transition: transform 0.2s ease; +} + +.tool-call-chevron .codicon { + font-size: 10px; +} + +/* Figma: chevron rotated 180deg when expanded (pointing up), -90deg when collapsed */ +.tool-call-card.expanded .tool-call-chevron { + transform: rotate(180deg); +} + +.tool-call-card:not(.expanded) .tool-call-chevron { + transform: rotate(0deg); +} + +.tool-call-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--vscode-foreground); +} + +.tool-call-icon .codicon { + font-size: 12px; +} + +.tool-call-header-wrapper { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + overflow: hidden; +} + +.tool-call-title { + font-size: 11px; + font-weight: 400; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 1; +} + +.tool-call-remove { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: transparent; + border: none; + border-radius: 3px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + opacity: 0; + transition: all 0.15s ease; +} + +.tool-call-header:hover .tool-call-remove { + opacity: 1; +} + +.tool-call-remove:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-errorForeground); +} + +/* Card Body - Compact AI response and user response */ +.tool-call-body { + display: none; + padding: 6px 8px; +} + +.tool-call-card.expanded .tool-call-body { + display: block; +} + +.tool-call-ai-response { + font-size: 11px; + line-height: 1.35; + color: var(--vscode-foreground); + margin-bottom: 6px; + word-wrap: break-word; +} + +.tool-call-ai-response p { + margin: 0 0 4px 0; +} + +.tool-call-ai-response p:last-child { + margin-bottom: 0; +} + +/* Markdown headers in AI response */ +.tool-call-ai-response h1, +.tool-call-ai-response h2, +.tool-call-ai-response h3, +.tool-call-ai-response h4, +.tool-call-ai-response h5, +.tool-call-ai-response h6, +.pending-ai-question h1, +.pending-ai-question h2, +.pending-ai-question h3, +.pending-ai-question h4, +.pending-ai-question h5, +.pending-ai-question h6, +.pending-ai-summary h1, +.pending-ai-summary h2, +.pending-ai-summary h3, +.pending-ai-summary h4, +.pending-ai-summary h5, +.pending-ai-summary h6 { + margin: 12px 0 8px 0; + font-weight: 600; + color: var(--vscode-foreground); + line-height: 1.3; +} + +.tool-call-ai-response h1, +.pending-ai-question h1, +.pending-ai-summary h1 { + font-size: 1.5em; +} + +.tool-call-ai-response h2, +.pending-ai-question h2, +.pending-ai-summary h2 { + font-size: 1.3em; +} + +.tool-call-ai-response h3, +.pending-ai-question h3, +.pending-ai-summary h3 { + font-size: 1.15em; +} + +.tool-call-ai-response h4, +.pending-ai-question h4, +.pending-ai-summary h4 { + font-size: 1.05em; +} + +.tool-call-ai-response h5, +.pending-ai-question h5, +.pending-ai-summary h5 { + font-size: 1em; +} + +.tool-call-ai-response h6, +.pending-ai-question h6, +.pending-ai-summary h6 { + font-size: 0.9em; + color: var(--vscode-descriptionForeground); +} + +/* Lists in AI response */ +.tool-call-ai-response ul, +.tool-call-ai-response ol, +.pending-ai-question ul, +.pending-ai-question ol, +.pending-ai-summary ul, +.pending-ai-summary ol { + margin: 8px 0; + padding-left: 24px; +} + +.tool-call-ai-response li, +.pending-ai-question li, +.pending-ai-summary li { + margin: 4px 0; +} + +/* Blockquotes in AI response */ +.tool-call-ai-response blockquote, +.pending-ai-question blockquote, +.pending-ai-summary blockquote { + margin: 8px 0; + padding: 8px 12px; + border-left: 3px solid var(--vscode-textBlockQuote-border, var(--vscode-focusBorder)); + background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1)); + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* Horizontal rule in AI response */ +.tool-call-ai-response hr, +.pending-ai-question hr, +.pending-ai-summary hr { + border: none; + border-top: 1px solid var(--vscode-panel-border); + margin: 12px 0; +} + +/* Markdown tables */ +.tool-call-ai-response .markdown-table, +.pending-ai-question .markdown-table, +.pending-ai-summary .markdown-table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 12px; +} + +.tool-call-ai-response .markdown-table th, +.pending-ai-question .markdown-table th, +.pending-ai-summary .markdown-table th { + background-color: var(--vscode-editor-selectionBackground, rgba(127, 127, 127, 0.15)); + border: 1px solid var(--vscode-panel-border); + padding: 8px 12px; + text-align: left; + font-weight: 600; + color: var(--vscode-foreground); +} + +.tool-call-ai-response .markdown-table td, +.pending-ai-question .markdown-table td, +.pending-ai-summary .markdown-table td { + border: 1px solid var(--vscode-panel-border); + padding: 8px 12px; + color: var(--vscode-foreground); +} + +.tool-call-ai-response .markdown-table tr:nth-child(even), +.pending-ai-question .markdown-table tr:nth-child(even), +.pending-ai-summary .markdown-table tr:nth-child(even) { + background-color: var(--vscode-list-hoverBackground, rgba(127, 127, 127, 0.05)); +} + +.tool-call-ai-response .markdown-table tr:hover, +.pending-ai-question .markdown-table tr:hover, +.pending-ai-summary .markdown-table tr:hover { + background-color: var(--vscode-list-activeSelectionBackground, rgba(127, 127, 127, 0.1)); +} + +/* Code block styling - uses VS Code theme colors */ +.tool-call-ai-response .code-block, +.pending-ai-question .code-block, +.pending-ai-summary .code-block { + background-color: var(--vscode-textCodeBlock-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + padding: 12px; + margin: 8px 0; + overflow-x: auto; + font-family: var(--vscode-editor-font-family, 'Consolas', 'Courier New', monospace); + font-size: 13px; + line-height: 1.4; +} + +.tool-call-ai-response .code-block code, +.pending-ai-question .code-block code, +.pending-ai-summary .code-block code { + background: none; + padding: 0; + font-family: inherit; + font-size: inherit; + white-space: pre; + display: block; + color: var(--vscode-editor-foreground); +} + +/* Inline code styling - uses VS Code theme colors */ +.tool-call-ai-response .inline-code, +.pending-ai-question .inline-code, +.pending-ai-summary .inline-code { + background-color: var(--vscode-textCodeBlock-background, var(--vscode-editor-background)); + color: var(--vscode-editor-foreground); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--vscode-editor-font-family, 'Consolas', 'Courier New', monospace); + font-size: 12px; +} + +/* Markdown links in AI response */ +.tool-call-ai-response .markdown-link, +.pending-ai-question .markdown-link, +.pending-ai-summary .markdown-link { + color: var(--vscode-textLink-foreground); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.tool-call-ai-response .markdown-link:hover, +.pending-ai-question .markdown-link:hover, +.pending-ai-summary .markdown-link:hover { + color: var(--vscode-textLink-activeForeground); +} + +/* Bold and italic */ +.tool-call-ai-response strong { + font-weight: 600; +} + +.tool-call-ai-response em { + font-style: italic; +} + +/* User response section - compact with separator */ +.tool-call-user-section { + border-top: 1px solid var(--vscode-panel-border); + padding-top: 6px; + margin-top: 2px; +} + +.tool-call-user-response { + font-size: 11px; + line-height: 1.35; + color: var(--vscode-foreground); +} + +.tool-call-badge { + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + margin-left: 6px; +} + +.tool-call-badge.queue { + background-color: var(--vscode-textLink-foreground, #3794ff); + color: #ffffff; +} + +/* Pending AI Summary - Rendered markdown content from summary field */ +.pending-ai-summary { + padding: 10px 12px; + font-size: 13px; + line-height: 1.4; + color: var(--vscode-foreground); + word-wrap: break-word; + border-bottom: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + margin-bottom: 4px; +} + +/* Pending AI Question - Plain text bubble (no border) */ +.pending-ai-question { + padding: 10px 12px; + font-size: 13px; + line-height: 1.4; + color: var(--vscode-foreground); + word-wrap: break-word; +} + +/* Session Started Notice - Brief confirmation after new session */ +.session-started-notice { + padding: 12px 4px; + font-size: 13px; + color: var(--vscode-textLink-foreground, #3794ff); + line-height: 1.4; + display: flex; + align-items: center; + gap: 8px; +} + +/* Working State Indicator - Animated dots */ +.working-indicator { + padding: 12px 4px; + /* Match tool-history-area padding */ + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.4; + display: flex; + align-items: center; + justify-content: flex-start; + /* Ensure left alignment */ + gap: 8px; +} + +.working-indicator::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--vscode-progressBar-background, #0078d4); + animation: working-pulse 1.4s ease-in-out infinite; +} + +@keyframes working-pulse { + + 0%, + 100% { + opacity: 0.4; + transform: scale(0.8); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + +/* Idle notice - agent finished without follow-up */ +.working-indicator.idle-notice::before { + animation: none; + opacity: 0.5; + background-color: var(--vscode-descriptionForeground, #888); +} + +/* New Session Prompt - shown after session terminated */ +.new-session-prompt { + padding: 12px 4px; + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: var(--vscode-descriptionForeground); +} + +.new-session-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border: none; + border-radius: 4px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.new-session-btn:hover { + background: var(--vscode-button-hoverBackground); +} + +/* Mermaid diagram container */ +.mermaid-container { + margin: 8px 0; + padding: 12px; + background-color: var(--vscode-textCodeBlock-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + overflow-x: auto; +} + +.mermaid-container .mermaid { + display: flex; + justify-content: center; +} + +.mermaid-container .mermaid svg { + max-width: 100%; + height: auto; +} + +.mermaid-container.error .mermaid { + display: block; +} + +/* Pending Message - Shows as plain text (no border) */ +.pending-message { + margin: 8px 4px; + background-color: transparent; + border: none; + border-radius: 0; + overflow: hidden; + flex-shrink: 0; + text-align: left; + /* Ensure left alignment */ +} + +/* --- Chips Container (inside input) --- */ +.chips-container { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 12px 0 12px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 4px; + font-size: 12px; + max-width: 200px; +} + +.chip-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.chip-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chip-remove { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.chip-remove:hover { + opacity: 1; +} + +/* --- Input Wrapper (Queue + Input combined) --- */ +.input-wrapper { + display: flex; + flex-direction: column; + flex-shrink: 0; + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 10px; + background-color: var(--vscode-editor-background); + overflow: hidden; + /* Must be hidden to preserve border-radius on corners */ + position: relative; +} + +.input-wrapper:focus-within { + border-color: var(--vscode-focusBorder); +} + +/* --- Queue Section - Integrated Design --- */ +.queue-section { + background-color: var(--vscode-editor-background); + border: none; + border-bottom: 1px solid var(--vscode-panel-border); + border-radius: 0; + flex-shrink: 0; + overflow: hidden; + margin: 0; +} + +.queue-section.hidden { + display: none !important; + border-bottom: none; +} + +.queue-section.collapsed .queue-list { + display: none; +} + +.queue-header { + padding: 6px 10px; + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + user-select: none; + color: var(--vscode-foreground); + transition: background-color 0.15s ease; +} + +.queue-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.queue-header .accordion-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + transition: transform 0.2s ease; +} + +.queue-section.collapsed .accordion-icon { + transform: rotate(-90deg); +} + +.queue-header-title { + flex: 1; +} + +.queue-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.queue-list { + max-height: var(--queue-list-max-height); + overflow-y: auto; + border-top: 1px solid var(--vscode-panel-border); +} + +.queue-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--vscode-panel-border); + font-size: 12px; + line-height: 1.3; + cursor: grab; + transition: background-color 0.15s ease; +} + +.queue-item:last-child { + border-bottom: none; +} + +.queue-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.queue-item:focus { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: -2px; +} + +.queue-item.dragging { + opacity: 0.5; + cursor: grabbing; +} + +.queue-item.drag-over { + background-color: var(--vscode-list-activeSelectionBackground); +} + +.queue-item .bullet { + width: 8px; + height: 8px; + min-width: 8px; + border-radius: 50%; + margin-top: 5px; + flex-shrink: 0; +} + +.queue-item .bullet.active { + background-color: var(--vscode-focusBorder); +} + +.queue-item .bullet.pending { + border: 1.5px solid var(--vscode-descriptionForeground); + background-color: transparent; +} + +.queue-item .text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); +} + +/* Attachment badge for queue items with images/files */ +.queue-item-attachment-badge { + display: flex; + align-items: center; + justify-content: center; + padding: 2px 4px; + margin-left: 4px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 4px; + flex-shrink: 0; +} + +.queue-item-attachment-badge .codicon { + font-size: 12px; +} + +.queue-item-actions { + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.queue-item:hover .queue-item-actions, +.queue-item:focus .queue-item-actions { + opacity: 1; +} + +.queue-item .edit-btn, +.queue-item .remove-btn { + background: transparent; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.queue-item .edit-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.queue-item .remove-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-errorForeground); +} + +/* Old inline edit input styling - kept for fallback */ +.queue-item .edit-input { + flex: 1; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + padding: 4px 8px; + outline: none; + width: 100%; +} + +.queue-item .edit-input:focus { + border-color: var(--vscode-focusBorder); +} + +.queue-empty { + padding: 16px 12px; + text-align: center; + font-size: 12px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* --- Input Area Container (wrapper for dropdown + input-wrapper) --- */ +.input-area-container { + position: relative; + flex-shrink: 0; + overflow: visible; + /* Allow autocomplete dropdown to overflow */ +} + +/* --- Autocomplete Dropdown --- */ +.autocomplete-dropdown { + position: absolute; + bottom: calc(100% + 4px); + /* Position above the input wrapper */ + left: 0; + right: 0; + max-height: 200px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); + border-radius: 8px; + box-shadow: 0 4px 12px var(--vscode-widget-shadow); + overflow: hidden; + z-index: 1000; +} + +.autocomplete-list { + max-height: 180px; + overflow-y: auto; +} + +.autocomplete-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; +} + +.autocomplete-item:hover, +.autocomplete-item.selected { + background-color: var(--vscode-list-hoverBackground); +} + +.autocomplete-item-icon { + display: flex; + align-items: center; + color: var(--vscode-descriptionForeground); +} + +.autocomplete-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.autocomplete-item-name { + font-size: 13px; + color: var(--vscode-foreground); +} + +.autocomplete-item-path { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.autocomplete-empty { + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +/* --- Context Dropdown --- */ +.context-dropdown { + position: absolute; + bottom: calc(100% + 4px); + /* Position above the input wrapper */ + left: 0; + right: 0; + max-height: 200px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); + border-radius: 8px; + box-shadow: 0 4px 12px var(--vscode-widget-shadow); + overflow: hidden; + z-index: 1000; +} + +.context-list { + max-height: 180px; + overflow-y: auto; +} + +.context-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; +} + +.context-item:hover, +.context-item.selected { + background-color: var(--vscode-list-hoverBackground); +} + +.context-item-icon { + display: flex; + align-items: center; + color: var(--vscode-descriptionForeground); +} + +.context-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.context-item-label { + font-size: 13px; + color: var(--vscode-foreground); + display: flex; + align-items: center; + gap: 6px; +} + +.context-item-desc { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-weight: 400; +} + +.context-item-detail { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} + +.context-empty { + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +/* --- Input Container --- */ +.input-container { + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 8px; + /* Parent wrapper handles border-radius */ + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: visible; + position: relative; + z-index: 10; +} + +/* Input row with textarea and send button */ +.input-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px 0 10px; +} + +/* Wrapper for input highlighter and textarea */ +.input-highlighter-wrapper { + flex: 1; + position: relative; + min-height: 24px; +} + +/* Highlighting overlay - mirrors textarea content and shows colors */ +.input-highlighter { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 4px 0; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + line-height: 1.4; + color: transparent; + /* Text invisible - only background highlights show through */ + white-space: pre-wrap; + word-wrap: break-word; + overflow: hidden; + pointer-events: none; + z-index: 1; +} + +/* Slash command highlight in overlay - background only */ +.input-highlighter .slash-highlight { + color: transparent; + /* Text is invisible */ + background-color: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 20%, transparent); + border-radius: 3px; + padding: 1px 2px; + margin: 0 -2px; +} + +/* Hash/file reference highlight in overlay - background only */ +.input-highlighter .hash-highlight { + color: transparent; + /* Text is invisible */ + background-color: color-mix(in srgb, var(--vscode-symbolIcon-fileForeground, #e8ab53) 20%, transparent); + border-radius: 3px; + padding: 1px 2px; + margin: 0 -2px; +} + +textarea#chat-input { + flex: 1; + position: relative; + z-index: 2; + border: none; + resize: none; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + padding: 4px 0; + background: transparent; + outline: none; + min-height: 24px; + max-height: var(--textarea-max-height); + color: var(--vscode-input-foreground); + /* Normal visible text */ + line-height: 1.4; + overflow-y: auto; + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE/Edge */ + width: 100%; +} + +textarea#chat-input::-webkit-scrollbar { + display: none; + /* Chrome/Safari */ +} + +textarea#chat-input::placeholder { + color: var(--vscode-input-placeholderForeground); + opacity: 1; + font-size: 11px; + /* Ensure placeholder is visible even with transparent text color */ +} + +/* Send Button - positioned at top right of input row */ +button#send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + border: none; + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +button#send-btn:hover { + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); +} + +/* Send button active state - when there's text in input */ +button#send-btn.has-text { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +button#send-btn.has-text:hover { + background-color: var(--vscode-button-hoverBackground); +} + +button#send-btn .codicon { + font-size: 12px; +} + +.actions-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + background: transparent; + position: relative; + overflow: visible; +} + +.autopilot-label { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-weight: 500; + user-select: none; +} + +.actions-left { + display: flex; + align-items: center; + gap: 4px; +} + +.actions-right { + display: flex; + align-items: center; + gap: 6px; +} + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: all 0.15s ease; +} + +.icon-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +/* Mode Selector */ +.mode-selector { + position: relative; +} + +.mode-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; +} + +.mode-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.mode-btn .codicon { + font-size: 10px; +} + +/* Mode Dropdown */ +.mode-dropdown { + position: fixed; + min-width: 160px; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + opacity: 0; + visibility: hidden; + transform: translateY(4px); + transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; +} + +.mode-dropdown.visible { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.mode-dropdown.hidden { + display: none; +} + +.mode-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; +} + +.mode-option .codicon { + font-size: 12px; + opacity: 0.9; +} + +.mode-option:first-child { + border-radius: 5px 5px 0 0; +} + +.mode-option:last-child { + border-radius: 0 0 5px 5px; +} + +.mode-option:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.mode-option.selected { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +/* Send button disabled state */ +button#send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.hidden { + display: none !important; +} + +/* Responsive adjustments */ +@media (max-width: 400px) { + .welcome-cards { + grid-template-columns: 1fr; + } + + .welcome-title { + font-size: 20px; + } +} + + +/* History Modal Overlay */ +.history-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 16px; +} + +.history-modal-overlay.hidden { + display: none; +} + +/* History Modal */ +.history-modal { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + width: 100%; + max-width: 320px; + max-height: 50vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.history-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + background-color: var(--vscode-input-background); +} + +.history-modal-title { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.history-modal-info { + flex: 1; + font-size: 10px; + color: var(--vscode-descriptionForeground); + text-align: left; + padding-left: 6px; +} + +.history-modal-clear-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: all 0.15s ease; +} + +.history-modal-clear-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-errorForeground); +} + +.history-modal-clear-btn.hidden { + display: none; +} + +.history-modal-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: all 0.15s ease; +} + +.history-modal-close-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.history-modal-list { + flex: 1; + overflow-y: auto; + padding: 8px; + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE/Edge */ +} + +.history-modal-list::-webkit-scrollbar { + display: none; + /* Chrome/Safari/Opera */ +} + +.history-modal-empty { + padding: 24px 12px; + text-align: center; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +/* History items list - simple flat list */ +.history-items-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* History grouped by sessions */ +.history-session-group { + margin-bottom: 16px; +} + +.history-session-group:last-child { + margin-bottom: 0; +} + +.history-session-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--vscode-sideBar-background); + border-radius: 4px; + margin-bottom: 6px; + border-left: 2px solid var(--vscode-focusBorder); + cursor: pointer; + transition: background-color 0.1s ease; +} + +.history-session-header:hover { + background: var(--vscode-list-hoverBackground); +} + +.history-session-chevron { + display: flex; + align-items: center; + transition: transform 0.2s ease; +} + +.history-session-chevron .codicon { + font-size: 10px; + color: var(--vscode-descriptionForeground); +} + +.history-session-group.collapsed .history-session-chevron { + transform: rotate(-90deg); +} + +.history-session-group.collapsed .history-session-items { + display: none; +} + +.history-session-group.collapsed .history-session-header { + margin-bottom: 0; +} + +.history-session-icon { + display: flex; + align-items: center; + color: var(--vscode-focusBorder); +} + +.history-session-icon .codicon { + font-size: 12px; +} + +.history-session-name { + font-weight: 600; + font-size: 11px; + color: var(--vscode-foreground); + flex: 1; +} + +.history-session-count { + font-size: 10px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 1px 6px; + border-radius: 8px; +} + +.history-session-items { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 10px; +} + +/* ===== EDIT MODE STYLES ===== */ + +/* Edit mode container for buttons in actions bar */ +.edit-actions-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; +} + +.edit-actions-container.hidden { + display: none; +} + +.edit-mode-label { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.edit-btn-group { + display: flex; + align-items: center; + gap: 4px; +} + +/* Edit mode buttons */ +.edit-cancel-btn, +.edit-confirm-btn { + width: 28px; + height: 28px; + border-radius: 4px; + transition: all 0.15s ease; +} + +.edit-cancel-btn { + color: var(--vscode-descriptionForeground); +} + +.edit-cancel-btn:hover { + background-color: var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1)); + color: var(--vscode-errorForeground); +} + +.edit-confirm-btn { + color: var(--vscode-descriptionForeground); +} + +.edit-confirm-btn:hover { + background-color: var(--vscode-inputValidation-infoBackground, rgba(0, 150, 0, 0.1)); + color: var(--vscode-testing-iconPassed, #4caf50); +} + +/* Input container edit mode styling */ +.input-container.edit-mode { + border-color: var(--vscode-focusBorder); + background-color: var(--vscode-input-background); +} + +/* Queue item editing state */ +.queue-item.editing { + background-color: var(--vscode-editor-selectionBackground); + border-left: 2px solid var(--vscode-focusBorder); + opacity: 0.7; +} + +.queue-item.editing .text { + font-style: italic; +} + +.queue-item.editing .text::after { + content: ' (editing...)'; + color: var(--vscode-descriptionForeground); + font-size: 10px; +} + +.queue-item.editing .queue-item-actions { + display: none; +} + +/* ===== APPROVAL BAR STYLES ===== */ + +/* Approval bar - integrated at top of input-wrapper */ +.approval-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--vscode-inputValidation-infoBackground, rgba(100, 100, 200, 0.15)); + border-bottom: 1px solid var(--vscode-panel-border); + animation: approval-fade-in 0.15s ease-out; +} + +.approval-bar.hidden { + display: none; +} + +@keyframes approval-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* Label on left side */ +.approval-label { + font-size: 12px; + color: var(--vscode-foreground); + font-weight: 400; +} + +/* Buttons container on right */ +.approval-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +/* Approval buttons - text style */ +.approval-btn { + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--vscode-font-family); + border: none; +} + +.approval-btn:focus { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +/* Reject button - text only, no background */ +.approval-reject-btn { + background: transparent; + color: var(--vscode-foreground); +} + +.approval-reject-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-errorForeground); +} + +/* Accept button - primary style */ +.approval-accept-btn { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.approval-accept-btn:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.approval-accept-btn:active, +.approval-reject-btn:active { + transform: scale(0.98); +} + +/* ===== CHOICES BAR STYLES ===== */ + +/* Choices bar - appears for multi-choice questions */ +.choices-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--vscode-inputValidation-infoBackground, rgba(100, 100, 200, 0.15)); + border-bottom: 1px solid var(--vscode-panel-border); + animation: choices-fade-in 0.15s ease-out; + gap: 12px; +} + +.choices-bar.hidden { + display: none; +} + +@keyframes choices-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* Label on left side */ +.choices-label { + font-size: 12px; + color: var(--vscode-foreground); + font-weight: 400; + flex-shrink: 0; +} + +/* Buttons container - flex wrap for multiple choices */ +.choices-buttons { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + flex: 1; +} + +/* Choice button - compact numbered style */ +.choice-btn { + padding: 4px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--vscode-font-family); + border: 1px solid var(--vscode-button-border, var(--vscode-panel-border)); + background-color: var(--vscode-button-secondaryBackground, var(--vscode-input-background)); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + min-width: 28px; + text-align: center; +} + +.choice-btn:hover { + background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground)); + border-color: var(--vscode-focusBorder); +} + +.choice-btn:focus { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.choice-btn:active { + transform: scale(0.98); +} + +/* Selected choice button */ +.choice-btn.selected { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-color: var(--vscode-button-background); +} + +.choice-btn.selected:hover { + background-color: var(--vscode-button-hoverBackground); + border-color: var(--vscode-button-hoverBackground); +} + +/* Actions container for All/Send buttons */ +.choices-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* Action buttons (All, Send) */ +.choices-action-btn { + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--vscode-font-family); + border: 1px solid var(--vscode-panel-border); + background: transparent; + color: var(--vscode-foreground); + white-space: nowrap; +} + +.choices-action-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.choices-action-btn:focus { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.choices-action-btn:active { + transform: scale(0.98); +} + +.choices-action-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.choices-action-btn:disabled:hover { + background: transparent; +} + +/* Send button - primary style when enabled */ +.choices-send-btn:not(:disabled) { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-color: var(--vscode-button-background); +} + +.choices-send-btn:not(:disabled):hover { + background-color: var(--vscode-button-hoverBackground); +} + +/* ===== SLASH COMMAND DROPDOWN STYLES ===== */ + +.slash-dropdown { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + right: 0; + max-height: 200px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); + border-radius: 8px; + box-shadow: 0 4px 12px var(--vscode-widget-shadow); + overflow: hidden; + z-index: 1001; +} + +.slash-dropdown.hidden { + display: none; +} + +.slash-list { + max-height: 180px; + overflow-y: auto; +} + +.slash-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.slash-item:last-child { + border-bottom: none; +} + +.slash-item:hover, +.slash-item.selected { + background-color: var(--vscode-list-hoverBackground); +} + +.slash-item-icon { + display: flex; + align-items: center; + color: var(--vscode-textLink-foreground, #3794ff); + padding-top: 2px; +} + +.slash-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.slash-item-name { + font-size: 13px; + font-weight: 500; + color: var(--vscode-textLink-foreground, #3794ff); +} + +.slash-item-preview { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 250px; +} + +/* Tooltip for slash dropdown items - show full prompt on hover */ +.slash-item { + position: relative; +} + +.slash-item::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 8px); + top: 0; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + padding: 10px 12px; + font-size: 12px; + color: var(--vscode-foreground); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + max-width: 300px; + overflow-y: auto; + box-shadow: 0 4px 12px var(--vscode-widget-shadow); + z-index: 200; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + pointer-events: none; +} + +.slash-item:hover::after { + opacity: 1; + visibility: visible; +} + +.slash-empty { + padding: 16px 12px; + text-align: center; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +/* Slash tag chip - shown in input */ +.slash-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background-color: var(--vscode-textLink-foreground, #3794ff); + color: #ffffff; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + position: relative; +} + +.slash-tag:hover { + opacity: 0.9; +} + +/* Slash tag tooltip on hover */ +.slash-tag-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + padding: 8px 12px; + font-size: 12px; + color: var(--vscode-foreground); + white-space: pre-wrap; + max-width: 300px; + max-height: 150px; + overflow-y: auto; + box-shadow: 0 4px 12px var(--vscode-widget-shadow); + z-index: 1002; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; +} + +.slash-tag:hover .slash-tag-tooltip { + opacity: 1; + visibility: visible; +} + +/* ===== SETTINGS MODAL STYLES ===== */ + +.settings-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 16px; +} + +.settings-modal-overlay.hidden { + display: none; +} + +.settings-modal { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + width: 100%; + max-width: 320px; + max-height: 50vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.settings-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + background-color: var(--vscode-input-background); +} + +.settings-modal-title { + flex: 1; + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.settings-modal-header-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.settings-modal-header-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: all 0.15s ease; +} + +.settings-modal-header-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.settings-modal-content { + flex: 1; + overflow-y: auto; + padding: 10px; + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE/Edge */ +} + +.settings-modal-content::-webkit-scrollbar { + display: none; + /* Chrome/Safari/Opera */ +} + +/* Settings Section */ +.settings-section { + margin-bottom: 10px; +} + +.settings-section:last-child { + margin-bottom: 0; +} + +.settings-section-title { + font-size: 11px; + font-weight: 600; + color: var(--vscode-foreground); + display: flex; + align-items: center; + gap: 6px; +} + +/* Info icon tooltip */ +.settings-info-icon { + display: inline-flex; + align-items: center; + margin-left: 4px; + cursor: help; +} + +.settings-info-icon .codicon { + font-size: 12px; + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.settings-info-icon:hover .codicon { + opacity: 1; +} + +/* Section header with toggle inline */ +.settings-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0; +} + +.settings-section-header .settings-section-title { + margin-bottom: 0; +} + +.settings-section>.form-row { + margin-top: 8px; +} + +/* Toggle Switch */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; +} + +.toggle-label { + font-size: 12px; + color: var(--vscode-foreground); +} + +.toggle-switch { + position: relative; + width: 36px; + height: 18px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 9px; + cursor: pointer; + transition: all 0.2s ease; +} + +.toggle-switch.active { + background-color: var(--vscode-textLink-foreground, #3794ff); + border-color: var(--vscode-textLink-foreground, #3794ff); +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background-color: var(--vscode-foreground); + border-radius: 50%; + transition: transform 0.2s ease; +} + +.toggle-switch.active::after { + transform: translateX(18px); + background-color: #ffffff; +} + +/* Reusable Prompts List */ +.prompts-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.prompt-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 10px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + transition: border-color 0.15s ease; + position: relative; +} + +/* Compact mode - single line with name only */ +.prompt-item.compact { + align-items: center; + padding: 5px 10px; +} + +.prompt-item.compact .prompt-item-content { + display: flex; + align-items: center; +} + +.prompt-item.compact .prompt-item-name { + margin-bottom: 0; +} + +.prompt-item:hover { + border-color: var(--vscode-focusBorder); +} + +.prompt-item-content { + flex: 1; + min-width: 0; +} + +.prompt-item-name { + font-size: 12px; + font-weight: 500; + color: var(--vscode-textLink-foreground, #3794ff); + margin-bottom: 2px; +} + +.prompt-item-text { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + word-break: break-word; + max-height: 40px; + overflow: hidden; +} + +.prompt-item-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; +} + +.prompt-item:hover .prompt-item-actions { + opacity: 1; +} + +.prompt-item-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: all 0.15s ease; +} + +.prompt-item-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.prompt-item-btn.delete:hover { + color: var(--vscode-errorForeground); +} + +/* Prompt item tooltip hidden - using native title instead */ +.prompt-item.compact::after { + display: none; +} + +/* ========== Autopilot Prompts List ========== */ +.autopilot-prompts-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + min-height: 24px; +} + +.empty-prompts-hint { + font-size: 11px; + color: var(--vscode-descriptionForeground); + padding: 8px; + text-align: center; + font-style: italic; +} + +.autopilot-prompt-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + transition: border-color 0.15s ease, transform 0.15s ease, opacity 0.15s ease; + cursor: grab; +} + +.autopilot-prompt-item:hover { + border-color: var(--vscode-focusBorder); +} + +.autopilot-prompt-item.dragging { + opacity: 0.5; + cursor: grabbing; +} + +.autopilot-prompt-item.drag-over-top { + border-top: 2px solid var(--vscode-focusBorder); +} + +.autopilot-prompt-item.drag-over-bottom { + border-bottom: 2px solid var(--vscode-focusBorder); +} + +.autopilot-prompt-drag-handle { + color: var(--vscode-descriptionForeground); + cursor: grab; + flex-shrink: 0; +} + +.autopilot-prompt-number { + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + margin-right: -2px; +} + +.autopilot-prompt-text { + font-size: 12px; + color: var(--vscode-foreground); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.autopilot-prompt-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; +} + +.autopilot-prompt-item:hover .autopilot-prompt-actions { + opacity: 1; +} + +/* Add Autopilot Prompt Form */ +.add-autopilot-prompt-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + padding: 10px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; +} + +/* ========== End Autopilot Prompts List ========== */ + +/* Add Prompt Form */ +.add-prompt-form { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + margin-top: 12px; +} + +.add-prompt-form.hidden { + display: none; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.form-label { + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + margin-top: 4px; +} + +.form-input { + padding: 6px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background-color: var(--vscode-editor-background); + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + font-size: 13px; +} + +.form-input:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} + +.form-textarea { + resize: vertical; + min-height: 60px; + max-height: 150px; +} + +/* Select dropdown styling */ +.form-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23888' d='M4.5 5.5L8 9l3.5-3.5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 12px; + padding-right: 28px; + cursor: pointer; + width: 100%; +} + +.form-select:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} + +/* Number input styling */ +.form-input[type="number"] { + width: 100%; +} + +/* Small inline inputs for human-like delay settings */ +.human-delay-range { + display: flex; + flex-direction: row !important; + align-items: center; + gap: 4px; + margin-bottom: 0; +} + +.form-label-inline { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.form-input-small { + width: 60px !important; + min-width: 60px; + padding: 4px 8px; + font-size: 12px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.form-btn { + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + border: none; + font-family: var(--vscode-font-family); +} + +.form-btn-cancel { + background: transparent; + color: var(--vscode-foreground); +} + +.form-btn-cancel:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.form-btn-save { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.form-btn-save:hover { + background-color: var(--vscode-button-hoverBackground); +} + +/* Add Prompt Button - inline (next to title) */ +.add-prompt-btn-inline { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: all 0.15s ease; +} + +.add-prompt-btn-inline:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-textLink-foreground, #3794ff); +} + +.add-prompt-btn-inline .codicon { + font-size: 14px; +} + +/* Empty state for prompts */ +.prompts-empty { + padding: 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 11px; + font-style: italic; +} + +/* ===== New Session Modal ===== */ +.new-session-modal { + max-width: 300px; +} + +.new-session-modal-content { + padding: 16px !important; +} + +.new-session-note { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin: 0 0 12px 0; + line-height: 1.4; + display: flex; + align-items: center; + gap: 6px; +} + +.new-session-warning { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin: 0 0 16px 0; + line-height: 1.4; +} + +.new-session-btn-row { + display: flex; + gap: 8px; + justify-content: flex-end; } \ No newline at end of file diff --git a/tasksync-chat/media/notification.wav b/tasksync-chat/media/notification.wav new file mode 100644 index 0000000000000000000000000000000000000000..20858535fba26abf1d05a8b659dbfc5153d6fc5e GIT binary patch literal 29150 zcmeI5<&#ub)b3BaySttaL4!+hw*UhP5(WtjBv`NncZUQEmcT$@7=k1)KybI<5;XK_ zcX!uw&%OV`t@q1Y-REmpt=g;l*|pBIpWp7`1N!#;Fc$zu^%&V_>Z~7>OaK5-$kY2T z0N6Ad1)u>YQ1i_X-<(Ij0{|F+0*HVnKo4Lnun5=-Tm;?$F@S42@p#;~n%lWy7))wO!*EB z0Vxz6qr~cPGCYibO!$$AAhh6M|wn56Gszf;cIZ+v3SgN)MNk)*O&Sg zllgPGHQ8C2h3WcKHn||FPL>kHWbfp;ZlheyUBe>F9@IE7hvnr_kfS(P?1<@otvL|o%%9~ zPS|6aXy53&NL}Py1QXpCogP~nf0*c-N~K?9ALWfjG~5a`8S^vlD4~wDg7O*d96is# zv0kx;vwvnUWh>cBSU)qzFkG~@)M4ZfM~Zd;}*)hRJNb|lgwj1DP+ zTLS$8qXO3h^MZ>*FT>NL1LHNx^XVqJ2Zj0N7N`K`Aifjn5`{$ngh^!AbCTRR?>D}i z{~v!Q{}^vQm&qB#>cR+9r;-~8@32;srEDs=v-tGD#P#UZ@QC16zsLu8`*_0cynCjn zr+0~u9>@pVMXtq`Cg)_f=Km>CQD0(r5dJ2gr7d9+ID>gz1s{Y|u|eEb(pGX)tQY+x z{K$XE9mhV#_>;PbB*q^?v*C3GOtv;TJ~lGEGa&XA++AG(2f?w|e!wnpkeoAJT|7Vf zD8W?t)A({i-gc5w`O}Umj=Gm;4SLPn(&&@Z@4=uxO)ee*EiB}lB7CD-@ zpCK2g0uQl5(jeLp7L&I|xL49uK3l0$57KxwDa}01c=c`NetAY>6@J6p$eKh$lNMqB zgIgAUORM6>kipm7?XuIYf17NELr_!?>BmD$4c$#UEj9KA*JR(k(6iY1w4mUG&tYeh zQ1o%^Is8WAMY1a83H1-!)8J6uDBS~azt*JQt$Zw7C;q^H&R#%wlbYiu0lNzy)7|6N z&=ViUebN5jve0zY@C|fH|A)Q>RAs=M`dO&<0j{_=6C4oxkoq}48rET*#1%9IGqW6pcQCLN~PQpYP^YlA@WzPlN-rszs)gG5 zV0+ypT>=bfhiX980{LLcMZqo3B1V$j3%>;QXVI7Gm(YjL`i<^cj(OIU2{0an{?*s& zF%a6Y*LcM|(YDsf^Wua3qfe5Ha^1@e%v-_&s+Y;dMCr#3#E1F0fabZI`c4hoe(GGDNd*^Xj+0o-Bg`e*9N?*X;8@&bm=zX zU)+UEm@L(PnznXg&zIXZhpfq{|R ziJEMqQWkXwKa&z*H0BC~^^$w?8r3Qd4a6bqXsjCv1~oC&_ljB4oKVe6G1pP81Q}*X zd42A`RnI)I225LOo7&uASQMUk;YFnvqDV|Cvi}v#7v52&3#FH4P%*>BVy^n4Q zZu3T*w`_RJ8)H+07&@%Kt6vH|H=Hp^tOUn*?$7+g!k6O1GpwQ&_!Bpg?5B6+d@itv z1@aF{mgbHY0>|n4=yrpHv~$%|rB3!-MB;~8KhfgEHrT1~{=73aGKLM6ydzvZ`w$Do z^p&9%^oRZ;qL4#|xu%zvzw9XYE#JFP>-f|3hQgPC3a2Mcqu*vfw66(rs)U1`9LNA7d**-}|n*X4!wU^fj$C41yZ; zf9ZQc^9*fFYb+z|`(1;5V?yU*z0!sJV|XvNisYg7VUOW+#ZzTHm9^>_+T-9Cy3x8P z;8CqleN+j_eiaA#PWBEuiaZdv0ytYpq{qf{A*)a6eq?u9)|&n@d<$LG*XtWY{SA3z z2TQ@$)@Afsf-Pd#Q*-j2VKVj!v4-}9<>CD$qR3K;@#-$xpTM5F2|642T&vaul`Z8Z zakXF^hsD@Levc!gJ})lKnBzZ&NBB3nHI7!+yC%r64*Ey`yPg9n4fl*ubG_}2bC!2% zup!zu<<6Zguf%jGTB(Cs^Lf2R4bndp3N>9j3v8>KtjmB2?bn*ts*UoAl4pVsoSh6h zWdeRL>QND!os@`&ANgbMRgN`Qw3%!?3cb}I(8KyX^po+Fd8lo+6ZV7yEusy{>YTQm zL|-OUQ(rO5T)nVLI#OX)nKXkz9ikA5P6+PMELIuhcO@N!UvOE>-4q{QjUHawnsq1U zL^=nmJr>7XYj1OB<3ErCQAkk#0UBwXVlG-4&R;wW0}YW+lW(%WmcBzbC;UgLVs7Ss zFN{gBiUX=sn#Ld(sUg#S4nEM_P&HGir1ioF+#O6Ybw1$?+FTOkW+o$%zXOjw-JGCp zqj`?e3#E`6kp2RsF{;g{tPPG&Jm^5nNPS{JmRd5R>hV>S*9c0?HChlZb7l)z5_-EuQ#(vH&L32s9oT=)nv1&u$H@eSt%RrsB zoBE}~BwZvr&HIU^rmZLbgGra0|eS?{`TS8c>7Gc@Br9>>qCA`S8=q0XJVZEu@bMjqWu6?>-y<_1HaTRS4)%~Wmb`r zPhcOT(MZFvYv4Bfozv#8|e$d`e6p18wdtupsBK8M_hLcID{&|u?e zbHG|~Eb~kZ?2XWqm$S=CgVAil4N80FT<&P0S(21*RUOu7Ko(L%rRxd4)I3#nRkV>_ z6&koFnQf@s2v5=ZQtRA?Br*CdVD=1mwzKUqFEvJ>9I}r8^rs<_k!Rj-t#zTl?0Lst!ZOht>s-p!5n%foBp(hO8M0sMe#OukGfap;1} z;-u`VGOupXz6QV64b=S(j?->dH&G6fr9|!dO7^6Kd>R=IUUd zY0;Rb8ahKK_2={>pq~w+O%0Y!_K&U|zM~;HwmUtvAO(EbI#OHuHugb&mAF1Rn9Y-6ChP?x608$t(scGph(Wy^TePQys( zy#A!VBlL|yW~#BYu+MWTe2qfOW7PD6{0?|LRzf;Y`v?#+mK$CSkgNfIDc%T5CR@mf zC6j%0-TfWItp-!bumgIkKd7fbEW@A1cjh&=Q_k+* zcEP1lIN6X}RPKi%5&xu$S$%jS(Prre1x{ViOaxmXDuh8aI7c&7byB`jk`~~(|1f$} z_Tpcn%EiyK`x4qn+AsC|;i$7p&2r;eNRRw_Gx{L3z_`ua%r?OJ)^j^hj4V!qxnSuA zdKKXlYAy2>w?WuM+D>s_^+eMRR3HlB=yc#Q&2Cju?v+dyF6Z`UKBXuLU!%8_9%QA- zqmh|`jUJpcVVz*EGQNfUhzgzh$50RBK(oQ>b$sjTA6OnqCJtmLmOevg@O2b9a}2ka z@R`IRU#Z%ok%Ba&hE_KK{HXa)HAK-*`bwDK-eV4>o+7x>{PK|8k)%3m4NA%>mvK`>3SUEWDGL6gzu zz{$GSx@jOuD^wp+9Fukweaq|4dP{9hoQtU|f6RT7s*8RdoaBA%JYe&gZy03;7WBLR zp?)J|F+4VPvNm=6;-27N6Ar{TX9g67C#3)57MLT48Klnmz`2Z)W@~Y!7;kQx?|u}?Ot^!gK>JclbJO>R9?+lq2K^9donf%)uw{Y$zH6~> zOUM;loNiqJfEU<}Bnf>1dn3P#c#CYJ@`8Gq_B>dn`$~5k{6+glypVdAf}0*lB0AZj7wDi^AUY4?GBb=A6$;0-NR^GZpVzZbUj~2%!y@R8{)k=*eP;M*q*)%?a?anpdxQ4q^pq^`EnmhgB=Tr0 zSVwr@i{42eDmtiD+QnceM1>L1q3x{^sHVs}NDc}9;EZSJ$&K*iQQM0zGR+cq!oT?+ zxyL#tS-qx=VITAYQ6T{$8IBkqn&;Z~IU9TB!AVhPa#yap+yoOQ?4+Vt&3IVR0_g%p zRu$8X0h=OI7Dh(`muSYRuFCgIs6si{%$PtqkN2a*rIFcliLMb^prz-k&u_q;w`MK>ty)X4@riMAilldW24_ZKipw5rRTU4V(Th)ZW;| z{M7p1QRQhFsEOzktFpaIT(kkdk%DFR;5HLpmb{hEQ*F_3K@zf#PjtgTyT+*+ulQQ( z6q0#H<}~U*1T>~qd3x?f^7CjuK=v+nj|4YUsV;x~Z{M>{#yZ=bsjS9sfSltXKpd z;g*uIj4Do*AR%rfcPV9>$65g+fE(u5?{t4$v8wB(0@?WZU@L z#II$wN{9M)?IUoMZiwy_I9pq%9-y2plZ&VDtJq1}cv3y~157FmOW%r3361jAyQ=M5 zES*dX4Sk{0`ZM|g73ZYloK&2XigQwNPAbkx#W|@sCl%+U;+#~RlZtavaZW1ENyRy- zI42e7q~e@ZGAEVHNhNbq$(&R&CzZ@eC38~AoK!L=|D&0cIwbGM)J*~v+Aq{`g+%s7 z1oE|P3+-FdE9|Ghrb0P=D84xSyI<)cIj7osSn5pMj1;5Lc+qGQTsBzoc02ZxL$fj-bvX@^^fYds-;S)+$LWyrHGpfQk=QWv($UU zi`WCey5j0=T{0Ej5}M`z(=*idjl*XPSZ5%Q-|DoDbaZsB_q6ag56zFp69+Rjh4wHB z^BDgv`9B(kmE!!wzc1V^&PaUHDYEIZoRlZMC&r1~{Mp=7tU7uPr9@bU4Fi)(?%byI zSBbIFqoGcLM!p|CpSXLuo;mM1L08h%*mKW&!Ji9mhzyH=k(!hJtB@>zj-HR(L0nJi zOMlA5bK=|${9A(ELcEAAx-JBS9{zmZY0iFDAI2qWo+Kt{uqu?Y+_X@Y-JfD7?nf_% zqrs(tvHsn@PkmzFw?4X`8mJCtL;6T2HZb`xJvqlKzAkS?b;iEO4Y3^+{%XmYJ_F^e*|!+kvL&YnZmUJ@^lV1kp$On>>_qgOa6SsAkF*3XxJx z-b~s>TtOI$r{b<+s?kN@Al#>%FWxEC=6B^zWX+k5nXBn<(yh{s(}U7G)1~xJnGRW0 z&XI%i)c*n(SM?+p;hSjsC5WeV*_>qy@4Wp z61rUIL zhlj$Y@|ki?87wDDZ%YqLFH514raY(orrZa91b+#*fkh}RdL24}9)#J9IgUAoS&2-( z&(Xcmhfoec2=szyl@FDS#g@hTLdQZj@5q<){R?*rHN_SsaajSk28N)fqGw|!Vn4%u zzzxK2#Bae5!$Y{HxB=LX7%ch{FdEL5juvYQ1M*{Y$FeQ52)b}N|joh z>X-gDqtEWjuP+`dKL-kEA&yRXO6*D=Lt#O`zJOh+oyYFYxB)YR`?)#Anp}`N5)aN(~i(vF*-4>(9h67>Zjyq z1O=`Ox*OcBI3{-@-8R+TuM8lD|qSX+LEBIC5gB6$&QC8Nyr zus&nIW4&j7&S2BNCr=@;usZ=vX=AQix?kc3^0wG7v?};r5EbelZXG=zZ%8q7SBe{e zQCJdjDdi`+oF(PV;nI0wE|a^2-GMogMj(HIACB%+Zk+F%J|Eu^DFs;pi*LT~ov-Bg z2G>Qd#b>A2Ib}*vWU@_Gn4;9I85vn|19nRfSNFmR>j#Q^h*0HH>rQoj75^(9Tc-AC-`*MTXJ)3b?Mi%I@;Qgaow@* zLQ?%+XuV;UX@~WllkJyB@1$>*K4M={CbP!~7t8jj)@y|btJ4O2sFo?J#eZ>o(nk>* z1BEO#ejs3Xac$#GHiXSF>%TF4W^Q5c>}eFbop_MXq9mjMqba{$qEW8W)PhqG5+{go zIJ2Z8L5kU)EW>yU|0SD*72crzw)qyq1=k@|u){drN^{lwi%~-c3%A3!pq=Ne70r_O zQG2vq5DM6cFu)_^lSSJ&L>d(jmCtA1M_2nJ&f(TK#vKUtOF@|5cV>d4yLVU^PO^&q zFcZmQ*5`uF(htgl20{p}-3UcPRc@4!`2R5C#Fwc3`F#m&Nb2F)rRJ{;dW7i>LUT>c zY$*5bKFlSJ+t@9o8R?^u?|nGu zZ|VmDvX!bCS{g#=4gh`XA&Otc3GNR1 zDZ&b1NN#N09#Fdb+18pe2$B0v-`_wq7i>khB{(}VCtr_xLOjIq@TN&piV2#ZkaWEb zAzXsWW729tch)BIT+HXiPf|aICwVmvpBegpOv^^-p0ShljdOy(A-Xwpw(P)Jsna>V zMV;gd^}pJ-NDd=`@GBeTb)pZPQM3v8<}fy^i9PZ+ajmf?5uUalp=r+=PMKfZVGl0c zki1n$q8a2@%nZLudP-^1JO`&De98_@C*@tq5I&39i=;+}^6tbJp=!@SdvEji1_#2U ze1Ha|v`rCil>{zVUzJ-$a_%wOdHfo9WOh!B7WmF}-YPI%MmW`keve_bdB6Rp z=Xz*-a(dw~`X*^D^8#NeU7@_Kxruc1BEo%qt1L)<;g4hPAuT}nD6~uN4IT8Xw9hne zGK3Hc^)B?O>8W*tt7TwcY*qFz*nodQ>%oOZZ{^$7T!cFPME9rGsS3!P!XkSbbtxU8_cz3k6uG4$2YUf}5n_2p`~+*;}zM1NAP4wUg;N!dQ6qOAVvV zHTGqmc_DgIP#BD!NovoW!#7K+l!rB^k#0UfXwTisHqsCLlT0`1DSCHdL(&>DdoJ4d zn2#EA$lVjCA-rk7b%4w79}sJn9Sv{7ub|~QcSVQgGt^~mZ-nN2fUulOxmMJNbCr4( zx1l^Svnkryf5EA-o-?jM=uIKQZo=knjg1b_bN{G;!lMq&O zsK%riEP23N#5hb`g6fy=m$)7@x~;a9sk7lE5`>JbQwNzjec&QJ}KsfEamDwLVnb|rCoRHU)*xnriKm9aCTkP*nO5DgZ!bBFIq zWOw>P$%l|L3?E3Fw`>HsL%`Don&W(c}*pA6uZ<*wL;{s^;zof zijw#R&&>FaRF8gBXqJkHNBed&=)7TzSo&HXSZZuM=WS1y;CC^4 zrd$Lt7UER;WX_)gq2y;-T;5GFU%p3rRg~i`W=*2n@y$>_79y!<(GJ1!-tn&Cj=}a3 z_9>2qt{=UDKj-ZDy@Spuy1wB&0kL3&xdL^zqZll2Mh3*u)Oxa7@> zlD8s-K+=21{ew&BI_qlf+3mX)+!Qq?f6mP=kHUzETc~T87S1~U7~xRSMA25EnP0!TB$I*q!4cXVlmZ%lDout0BWlSLl z!)wal%2)9tTnuLpa|rDk=>zT|>P+eHTrPDu{y4HB6b-NgasQ5hGiVS07+asrWKI^A z!2K~%d=+^J?FHis3ugbyS;e`^9>nU+xJtcCT8uBDXTy=gf$T4-aQvTWlStF>zadc= z9a$aS6vw8FnRLEwc{%C~_6%Vnxq-T#{*JMed70Ul*_&~Lc7rmSbR2&da~HT@vgO-m z|4SJX!{hy952G)m6Jm4Yg+w6LGJ7dMxugW%qHAzB2~S81DOad#X>Vxtv?A3;nLu7g zoQ!8;52G5x_ls-tOR_i9V^U+14--cdk;L)jy;QqQJnPNlN^RgtsHK=$xK9Yb5wDV# zlPzR3c^Ua8=_GM5;cr|RLqn^8F6Ft!7x@XfR@vd1`{^y|<7q+WLk7x<^P397(zWsg zActCvd5z8D?D#E&00B*WO&Ci!hQExf#g4-e(IKt*je0PI$wSP+WsF*+#_r?_7z5pnS|bjx(Rq;89b?cv(%>a zq`0~`wm7D^uz0aZD=jM(OPk7K_%FC4a2;rl+JQ2mIOtaBPUvQ6D%yamMGZ$`Q0IVe m03Prf-UojN_k~-+pCSx+M|cQ44c-X<0pEv>$Ui9-;r{~{XN~Rv literal 0 HcmV?d00001 diff --git a/tasksync-chat/media/remote-fallback.css b/tasksync-chat/media/remote-fallback.css new file mode 100644 index 0000000..2bd3744 --- /dev/null +++ b/tasksync-chat/media/remote-fallback.css @@ -0,0 +1,213 @@ +/** + * TaskSync Remote - CSS Variable Fallbacks + * Defines --vscode-* CSS variables for standalone browser mode (Remote PWA) + * Uses VS Code dark theme colors + */ + +:root { + /* Core colors */ + --vscode-foreground: #cccccc; + --vscode-focusBorder: #007fd4; + --vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --vscode-font-size: 13px; + --vscode-font-weight: 400; + + /* Sidebar / Background */ + --vscode-sideBar-background: #1e1e1e; + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBarSectionHeader-background: #252526; + --vscode-sideBarTitle-foreground: #bbbbbb; + + /* Editor */ + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-border: #454545; + + /* Input */ + --vscode-input-background: #3c3c3c; + --vscode-input-border: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-placeholderForeground: #808080; + --vscode-inputOption-activeBorder: #007acc; + + /* Button */ + --vscode-button-background: #0e639c; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #1177bb; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-secondaryForeground: #cccccc; + --vscode-button-secondaryHoverBackground: #45494e; + + /* List / Dropdown */ + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-list-focusBackground: #062f4a; + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-border: #3c3c3c; + --vscode-dropdown-foreground: #f0f0f0; + + /* Scrollbar */ + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + + /* Badge */ + --vscode-badge-background: #4d4d4d; + --vscode-badge-foreground: #ffffff; + + /* Text colors */ + --vscode-textLink-foreground: #3794ff; + --vscode-textLink-activeForeground: #3794ff; + --vscode-descriptionForeground: #9d9d9d; + + /* Borders */ + --vscode-panel-border: #2b2b2b; + --vscode-contrastBorder: transparent; + + /* Checkbox */ + --vscode-checkbox-background: #3c3c3c; + --vscode-checkbox-border: #3c3c3c; + --vscode-checkbox-foreground: #f0f0f0; + + /* Menu */ + --vscode-menu-background: #252526; + --vscode-menu-foreground: #cccccc; + --vscode-menu-selectionBackground: #094771; + --vscode-menu-selectionForeground: #ffffff; + --vscode-menu-separatorBackground: #454545; + + /* Notifications */ + --vscode-notificationCenter-border: #303031; + --vscode-notifications-background: #252526; + --vscode-notifications-foreground: #cccccc; + + /* Icons */ + --vscode-icon-foreground: #c5c5c5; + + /* Error / Warning / Info */ + --vscode-errorForeground: #f48771; + --vscode-editorError-foreground: #f14c4c; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorInfo-foreground: #3794ff; + + /* Git colors */ + --vscode-gitDecoration-addedResourceForeground: #81b88b; + --vscode-gitDecoration-modifiedResourceForeground: #e2c08d; + --vscode-gitDecoration-deletedResourceForeground: #c74e39; + + /* Panel */ + --vscode-panel-background: #1e1e1e; + + /* Misc */ + --vscode-quickInput-background: #252526; + --vscode-quickInputList-focusBackground: #062f4a; + --vscode-keybindingLabel-background: rgba(128, 128, 128, 0.17); + --vscode-keybindingLabel-foreground: #cccccc; + --vscode-keybindingLabel-border: rgba(51, 51, 51, 0.6); + --vscode-keybindingLabel-bottomBorder: rgba(68, 68, 68, 0.6); +} + +/* Remote-specific styling */ +body.remote-mode { + padding-top: 48px; + /* Account for fixed header */ +} + +/* Remote header bar */ +.remote-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 48px; + background: #252526; + border-bottom: 1px solid #3c3c3c; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 1000; +} + +.remote-header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.remote-header-title { + font-weight: 600; + font-size: 14px; + color: #fff; +} + +.remote-status { + width: 8px; + height: 8px; + border-radius: 50%; + background: #808080; +} + +.remote-status.connected { + background: #4ec9b0; +} + +.remote-status.disconnected { + background: #f14c4c; +} + +.remote-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.remote-btn { + padding: 6px 12px; + background: #3c3c3c; + color: #cccccc; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +} + +.remote-btn:hover { + background: #4c4c4c; +} + +/* Hide VS Code-only settings in remote mode */ +.remote-mode .vscode-only { + display: none !important; +} + +/* Mobile optimizations */ +@media (max-width: 480px) { + .remote-header { + padding: 0 12px; + } + + .main-container { + padding: 8px; + } + + .input-container { + padding: 8px; + } +} + +/* Noscript fallback message */ +.noscript-message { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + font-family: system-ui; + color: #ccc; + background: #1e1e1e; +} \ No newline at end of file diff --git a/tasksync-chat/media/webview-body.html b/tasksync-chat/media/webview-body.html new file mode 100644 index 0000000..6cec162 --- /dev/null +++ b/tasksync-chat/media/webview-body.html @@ -0,0 +1,125 @@ +
+ +
+ +
+
+ +
+

{{TITLE}}

+

{{SUBTITLE}}

+ +
+
+
+ + Normal +
+

Respond to each AI request directly. Full control over every + interaction.

+
+
+
+ + Queue +
+

Batch your responses. AI consumes from queue automatically, one by one. +

+
+
+ +

Tip: Enable Autopilot to automatically respond to + ask_user prompts without waiting for your input, using a customizable prompt you can configure in + Settings. Queued prompts always take priority over Autopilot responses. Configure the session timeout in + settings to avoid keeping copilot session alive when you're away.

+

The session timer tracks how long you've been using one premium request. + It is advisable to start a new session and use another premium request prompt after 2-4 + hours or 50 tool calls.

+
+ + +
+ + + + + + +
+ + +
+ + + + +
+ + + + +
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+
+ Autopilot +
+ +
+
+
+ + +
+
+
\ No newline at end of file diff --git a/tasksync-chat/media/webview.js b/tasksync-chat/media/webview.js index 8bc048b..651c874 100644 --- a/tasksync-chat/media/webview.js +++ b/tasksync-chat/media/webview.js @@ -1,3330 +1,5056 @@ -/** - * TaskSync Extension - Webview Script - * Handles tool call history, prompt queue, attachments, and file autocomplete - */ -(function () { - const vscode = acquireVsCodeApi(); - - // Restore persisted state (survives sidebar switch) - const previousState = vscode.getState() || {}; - - // State - let promptQueue = []; - let queueEnabled = true; // Default to true (Queue mode ON by default) - let dropdownOpen = false; - let currentAttachments = previousState.attachments || []; // Restore attachments - let selectedCard = 'queue'; - let currentSessionCalls = []; // Current session tool calls (shown in chat) - let persistedHistory = []; // Past sessions history (shown in modal) - let lastContextMenuTarget = null; // Tracks where right-click was triggered for copy fallback behavior - let lastContextMenuTimestamp = 0; // Ensures stale right-click targets are not reused for copy - let pendingToolCall = null; - let isProcessingResponse = false; // True when AI is processing user's response - let isApprovalQuestion = false; // True when current pending question is an approval-type question - let currentChoices = []; // Parsed choices from multi-choice questions - - // Settings state - let soundEnabled = true; - let interactiveApprovalEnabled = true; - let sendWithCtrlEnter = false; - let autopilotEnabled = false; - let autopilotText = ''; - let autopilotPrompts = []; - let responseTimeout = 60; - let sessionWarningHours = 2; - let maxConsecutiveAutoResponses = 5; - // Keep timeout options aligned with select values to avoid invalid UI state. - var RESPONSE_TIMEOUT_ALLOWED_VALUES = new Set([0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 150, 180, 210, 240]); - var RESPONSE_TIMEOUT_DEFAULT = 60; - // Human-like delay: random jitter simulates natural reading/typing time - let humanLikeDelayEnabled = true; - let humanLikeDelayMin = 2; // minimum seconds - let humanLikeDelayMax = 6; // maximum seconds - var CONTEXT_MENU_COPY_MAX_AGE_MS = 30000; - - // Tracks local edits to prevent stale settings overwriting user input mid-typing. - let reusablePrompts = []; - let audioUnlocked = false; // Track if audio playback has been unlocked by user gesture - - // Slash command autocomplete state - let slashDropdownVisible = false; - let slashResults = []; - let selectedSlashIndex = -1; - let slashStartPos = -1; - let slashDebounceTimer = null; - - // Persisted input value (restored from state) - let persistedInputValue = previousState.inputValue || ''; - - // Edit mode state - let editingPromptId = null; - let editingOriginalPrompt = null; - let savedInputValue = ''; // Save input value when entering edit mode - - // Autocomplete state - let autocompleteVisible = false; - let autocompleteResults = []; - let selectedAutocompleteIndex = -1; - let autocompleteStartPos = -1; - let searchDebounceTimer = null; - - // DOM Elements - let chatInput, sendBtn, attachBtn, modeBtn, modeDropdown, modeLabel; - let inputHighlighter; // Overlay for syntax highlighting in input - let queueSection, queueHeader, queueList, queueCount; - let chatContainer, chipsContainer, autocompleteDropdown, autocompleteList, autocompleteEmpty; - let inputContainer, inputAreaContainer, welcomeSection; - let cardVibe, cardSpec, toolHistoryArea, pendingMessage; - let historyModal, historyModalOverlay, historyModalList, historyModalClose, historyModalClearAll; - // Edit mode elements - let actionsLeft, actionsBar, editActionsContainer, editCancelBtn, editConfirmBtn; - // Approval modal elements - let approvalModal, approvalContinueBtn, approvalNoBtn; - // Slash command elements - let slashDropdown, slashList, slashEmpty; - // Settings modal elements - let settingsModal, settingsModalOverlay, settingsModalClose; - let soundToggle, interactiveApprovalToggle, sendShortcutToggle, autopilotToggle, promptsList, addPromptBtn, addPromptForm; - let autopilotPromptsList, autopilotAddBtn, addAutopilotPromptForm, autopilotPromptInput, saveAutopilotPromptBtn, cancelAutopilotPromptBtn; - let responseTimeoutSelect, sessionWarningHoursSelect, maxAutoResponsesInput; - let humanDelayToggle, humanDelayRangeContainer, humanDelayMinInput, humanDelayMaxInput; - - function init() { - try { - console.log('[TaskSync Webview] init() starting...'); - cacheDOMElements(); - createHistoryModal(); - createEditModeUI(); - createApprovalModal(); - createSettingsModal(); - bindEventListeners(); - unlockAudioOnInteraction(); // Enable audio after first user interaction - console.log('[TaskSync Webview] Event listeners bound, pendingMessage element:', !!pendingMessage); - renderQueue(); - updateModeUI(); - updateQueueVisibility(); - initCardSelection(); - - // Restore persisted input value (when user switches sidebar tabs and comes back) - if (chatInput && persistedInputValue) { - chatInput.value = persistedInputValue; - autoResizeTextarea(); - updateInputHighlighter(); - updateSendButtonState(); - } - - // Restore attachments display - if (currentAttachments.length > 0) { - updateChipsDisplay(); - } - - // Signal to extension that webview is ready to receive messages - console.log('[TaskSync Webview] Sending webviewReady message'); - vscode.postMessage({ type: 'webviewReady' }); - } catch (err) { - console.error('[TaskSync] Init error:', err); - } - } - - /** - * Save webview state to persist across sidebar visibility changes - */ - function saveWebviewState() { - vscode.setState({ - inputValue: chatInput ? chatInput.value : '', - attachments: currentAttachments.filter(function (a) { return !a.isTemporary; }) // Don't persist temp images - }); - } - - function cacheDOMElements() { - chatInput = document.getElementById('chat-input'); - inputHighlighter = document.getElementById('input-highlighter'); - sendBtn = document.getElementById('send-btn'); - attachBtn = document.getElementById('attach-btn'); - modeBtn = document.getElementById('mode-btn'); - modeDropdown = document.getElementById('mode-dropdown'); - modeLabel = document.getElementById('mode-label'); - - queueSection = document.getElementById('queue-section'); - queueHeader = document.getElementById('queue-header'); - queueList = document.getElementById('queue-list'); - queueCount = document.getElementById('queue-count'); - chatContainer = document.getElementById('chat-container'); - chipsContainer = document.getElementById('chips-container'); - autocompleteDropdown = document.getElementById('autocomplete-dropdown'); - autocompleteList = document.getElementById('autocomplete-list'); - autocompleteEmpty = document.getElementById('autocomplete-empty'); - inputContainer = document.getElementById('input-container'); - inputAreaContainer = document.getElementById('input-area-container'); - welcomeSection = document.getElementById('welcome-section'); - cardVibe = document.getElementById('card-vibe'); - cardSpec = document.getElementById('card-spec'); - autopilotToggle = document.getElementById('autopilot-toggle'); - toolHistoryArea = document.getElementById('tool-history-area'); - pendingMessage = document.getElementById('pending-message'); - // Slash command dropdown - slashDropdown = document.getElementById('slash-dropdown'); - slashList = document.getElementById('slash-list'); - slashEmpty = document.getElementById('slash-empty'); - // Get actions bar elements for edit mode - actionsBar = document.querySelector('.actions-bar'); - actionsLeft = document.querySelector('.actions-left'); - } - - function createHistoryModal() { - // Create modal overlay - historyModalOverlay = document.createElement('div'); - historyModalOverlay.className = 'history-modal-overlay hidden'; - historyModalOverlay.id = 'history-modal-overlay'; - - // Create modal container - historyModal = document.createElement('div'); - historyModal.className = 'history-modal'; - historyModal.id = 'history-modal'; - - // Modal header - var modalHeader = document.createElement('div'); - modalHeader.className = 'history-modal-header'; - - var titleSpan = document.createElement('span'); - titleSpan.className = 'history-modal-title'; - titleSpan.textContent = 'History'; - modalHeader.appendChild(titleSpan); - - // Info text - left aligned after title - var infoSpan = document.createElement('span'); - infoSpan.className = 'history-modal-info'; - infoSpan.textContent = 'History is stored in VS Code globalStorage/tool-history.json'; - modalHeader.appendChild(infoSpan); - - // Clear all button (icon only) - historyModalClearAll = document.createElement('button'); - historyModalClearAll.className = 'history-modal-clear-btn'; - historyModalClearAll.innerHTML = ''; - historyModalClearAll.title = 'Clear all history'; - modalHeader.appendChild(historyModalClearAll); - - // Close button - historyModalClose = document.createElement('button'); - historyModalClose.className = 'history-modal-close-btn'; - historyModalClose.innerHTML = ''; - historyModalClose.title = 'Close'; - modalHeader.appendChild(historyModalClose); - - // Modal body (list) - historyModalList = document.createElement('div'); - historyModalList.className = 'history-modal-list'; - historyModalList.id = 'history-modal-list'; - - // Assemble modal - historyModal.appendChild(modalHeader); - historyModal.appendChild(historyModalList); - historyModalOverlay.appendChild(historyModal); - - // Add to DOM - document.body.appendChild(historyModalOverlay); - } - - function createEditModeUI() { - // Create edit actions container (hidden by default) - editActionsContainer = document.createElement('div'); - editActionsContainer.className = 'edit-actions-container hidden'; - editActionsContainer.id = 'edit-actions-container'; - - // Edit mode label - var editLabel = document.createElement('span'); - editLabel.className = 'edit-mode-label'; - editLabel.textContent = 'Editing prompt'; - - // Cancel button (X) - editCancelBtn = document.createElement('button'); - editCancelBtn.className = 'icon-btn edit-cancel-btn'; - editCancelBtn.title = 'Cancel edit (Esc)'; - editCancelBtn.setAttribute('aria-label', 'Cancel editing'); - editCancelBtn.innerHTML = ''; - - // Confirm button (✓) - editConfirmBtn = document.createElement('button'); - editConfirmBtn.className = 'icon-btn edit-confirm-btn'; - editConfirmBtn.title = 'Confirm edit (Enter)'; - editConfirmBtn.setAttribute('aria-label', 'Confirm edit'); - editConfirmBtn.innerHTML = ''; - - // Assemble edit actions - editActionsContainer.appendChild(editLabel); - var btnGroup = document.createElement('div'); - btnGroup.className = 'edit-btn-group'; - btnGroup.appendChild(editCancelBtn); - btnGroup.appendChild(editConfirmBtn); - editActionsContainer.appendChild(btnGroup); - - // Insert into actions bar (will be shown/hidden as needed) - if (actionsBar) { - actionsBar.appendChild(editActionsContainer); - } - } - - function createApprovalModal() { - // Create approval bar that appears at the top of input-wrapper (inside the border) - approvalModal = document.createElement('div'); - approvalModal.className = 'approval-bar hidden'; - approvalModal.id = 'approval-bar'; - approvalModal.setAttribute('role', 'toolbar'); - approvalModal.setAttribute('aria-label', 'Quick approval options'); - - // Left side label - var labelSpan = document.createElement('span'); - labelSpan.className = 'approval-label'; - labelSpan.textContent = 'Waiting on your input..'; - - // Right side buttons container - var buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'approval-buttons'; - - // No/Reject button (secondary action - text only) - approvalNoBtn = document.createElement('button'); - approvalNoBtn.className = 'approval-btn approval-reject-btn'; - approvalNoBtn.setAttribute('aria-label', 'Reject and provide custom response'); - approvalNoBtn.textContent = 'No'; - - // Continue/Accept button (primary action) - approvalContinueBtn = document.createElement('button'); - approvalContinueBtn.className = 'approval-btn approval-accept-btn'; - approvalContinueBtn.setAttribute('aria-label', 'Yes and continue'); - approvalContinueBtn.textContent = 'Yes'; - - // Assemble buttons - buttonsContainer.appendChild(approvalNoBtn); - buttonsContainer.appendChild(approvalContinueBtn); - - // Assemble bar - approvalModal.appendChild(labelSpan); - approvalModal.appendChild(buttonsContainer); - - // Insert at top of input-wrapper (inside the border) - var inputWrapper = document.getElementById('input-wrapper'); - if (inputWrapper) { - inputWrapper.insertBefore(approvalModal, inputWrapper.firstChild); - } - } - - function createSettingsModal() { - // Create modal overlay - settingsModalOverlay = document.createElement('div'); - settingsModalOverlay.className = 'settings-modal-overlay hidden'; - settingsModalOverlay.id = 'settings-modal-overlay'; - - // Create modal container - settingsModal = document.createElement('div'); - settingsModal.className = 'settings-modal'; - settingsModal.id = 'settings-modal'; - settingsModal.setAttribute('role', 'dialog'); - settingsModal.setAttribute('aria-labelledby', 'settings-modal-title'); - - // Modal header - var modalHeader = document.createElement('div'); - modalHeader.className = 'settings-modal-header'; - - var titleSpan = document.createElement('span'); - titleSpan.className = 'settings-modal-title'; - titleSpan.id = 'settings-modal-title'; - titleSpan.textContent = 'Settings'; - modalHeader.appendChild(titleSpan); - - // Header buttons container - var headerButtons = document.createElement('div'); - headerButtons.className = 'settings-modal-header-buttons'; - - // Report Issue button - var reportBtn = document.createElement('button'); - reportBtn.className = 'settings-modal-header-btn'; - reportBtn.innerHTML = ''; - reportBtn.title = 'Report Issue'; - reportBtn.setAttribute('aria-label', 'Report an issue on GitHub'); - reportBtn.addEventListener('click', function () { - vscode.postMessage({ type: 'openExternal', url: 'https://github.com/4regab/TaskSync/issues/new' }); - }); - headerButtons.appendChild(reportBtn); - - // Close button - settingsModalClose = document.createElement('button'); - settingsModalClose.className = 'settings-modal-header-btn'; - settingsModalClose.innerHTML = ''; - settingsModalClose.title = 'Close'; - settingsModalClose.setAttribute('aria-label', 'Close settings'); - headerButtons.appendChild(settingsModalClose); - - modalHeader.appendChild(headerButtons); - - // Modal content - var modalContent = document.createElement('div'); - modalContent.className = 'settings-modal-content'; - - // Sound section - simplified, toggle right next to header - var soundSection = document.createElement('div'); - soundSection.className = 'settings-section'; - soundSection.innerHTML = '
' + - '
Notifications
' + - '
' + - '
'; - modalContent.appendChild(soundSection); - - // Interactive approval section - toggle interactive Yes/No + choices UI - var approvalSection = document.createElement('div'); - approvalSection.className = 'settings-section'; - approvalSection.innerHTML = '
' + - '
Interactive Approvals
' + - '
' + - '
'; - modalContent.appendChild(approvalSection); - - // Send shortcut section - switch between Enter and Ctrl/Cmd+Enter send - var sendShortcutSection = document.createElement('div'); - sendShortcutSection.className = 'settings-section'; - sendShortcutSection.innerHTML = '
' + - '
Ctrl/Cmd+Enter to Send
' + - '
' + - '
'; - modalContent.appendChild(sendShortcutSection); - - // Autopilot section with cycling prompts list - var autopilotSection = document.createElement('div'); - autopilotSection.className = 'settings-section'; - autopilotSection.innerHTML = '
' + - '
' + - ' Autopilot Prompts' + - '' + - '' + - '
' + - '' + - '
' + - '
' + - ''; - modalContent.appendChild(autopilotSection); - - // Response Timeout section - dropdown for 10-120 minutes - var timeoutSection = document.createElement('div'); - timeoutSection.className = 'settings-section'; - timeoutSection.innerHTML = '
' + - '
' + - ' Response Timeout' + - '' + - '' + - '
' + - '
' + - '
' + - '' + - '
'; - modalContent.appendChild(timeoutSection); - - // Session Warning section - warning threshold in hours - var sessionWarningSection = document.createElement('div'); - sessionWarningSection.className = 'settings-section'; - sessionWarningSection.innerHTML = '
' + - '
' + - ' Session Warning' + - '' + - '' + - '
' + - '
' + - '
' + - '' + - '
'; - modalContent.appendChild(sessionWarningSection); - - // Max Consecutive Auto-Responses section - number input - var maxAutoSection = document.createElement('div'); - maxAutoSection.className = 'settings-section'; - maxAutoSection.innerHTML = '
' + - '
' + - ' Max Auto-Responses' + - '' + - '' + - '
' + - '
' + - '
' + - '' + - '
'; - modalContent.appendChild(maxAutoSection); - - // Human-Like Delay section - toggle + min/max inputs - var humanDelaySection = document.createElement('div'); - humanDelaySection.className = 'settings-section'; - humanDelaySection.innerHTML = '
' + - '
' + - ' Human-Like Delay' + - '' + - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '' + - '' + - '
'; - modalContent.appendChild(humanDelaySection); - - // Reusable Prompts section - plus button next to title - var promptsSection = document.createElement('div'); - promptsSection.className = 'settings-section'; - promptsSection.innerHTML = '
' + - '
Reusable Prompts
' + - '' + - '
' + - '
' + - ''; - modalContent.appendChild(promptsSection); - - // Assemble modal - settingsModal.appendChild(modalHeader); - settingsModal.appendChild(modalContent); - settingsModalOverlay.appendChild(settingsModal); - - // Add to DOM - document.body.appendChild(settingsModalOverlay); - - // Cache inner elements - soundToggle = document.getElementById('sound-toggle'); - interactiveApprovalToggle = document.getElementById('interactive-approval-toggle'); - sendShortcutToggle = document.getElementById('send-shortcut-toggle'); - autopilotPromptsList = document.getElementById('autopilot-prompts-list'); - autopilotAddBtn = document.getElementById('autopilot-add-btn'); - addAutopilotPromptForm = document.getElementById('add-autopilot-prompt-form'); - autopilotPromptInput = document.getElementById('autopilot-prompt-input'); - saveAutopilotPromptBtn = document.getElementById('save-autopilot-prompt-btn'); - cancelAutopilotPromptBtn = document.getElementById('cancel-autopilot-prompt-btn'); - responseTimeoutSelect = document.getElementById('response-timeout-select'); - sessionWarningHoursSelect = document.getElementById('session-warning-hours-select'); - maxAutoResponsesInput = document.getElementById('max-auto-responses-input'); - humanDelayToggle = document.getElementById('human-delay-toggle'); - humanDelayRangeContainer = document.getElementById('human-delay-range'); - humanDelayMinInput = document.getElementById('human-delay-min-input'); - humanDelayMaxInput = document.getElementById('human-delay-max-input'); - promptsList = document.getElementById('prompts-list'); - addPromptBtn = document.getElementById('add-prompt-btn'); - addPromptForm = document.getElementById('add-prompt-form'); - } - - function bindEventListeners() { - if (chatInput) { - chatInput.addEventListener('input', handleTextareaInput); - chatInput.addEventListener('keydown', handleTextareaKeydown); - chatInput.addEventListener('paste', handlePaste); - // Sync scroll between textarea and highlighter - chatInput.addEventListener('scroll', function () { - if (inputHighlighter) { - inputHighlighter.scrollTop = chatInput.scrollTop; - } - }); - } - if (sendBtn) sendBtn.addEventListener('click', handleSend); - if (attachBtn) attachBtn.addEventListener('click', handleAttach); - if (modeBtn) modeBtn.addEventListener('click', toggleModeDropdown); - - document.querySelectorAll('.mode-option[data-mode]').forEach(function (option) { - option.addEventListener('click', function () { - setMode(option.getAttribute('data-mode'), true); - closeModeDropdown(); - }); - }); - - document.addEventListener('click', function (e) { - var markdownLink = e.target && e.target.closest ? e.target.closest('a.markdown-link[data-link-target]') : null; - if (markdownLink) { - e.preventDefault(); - var markdownLinksApi = window.TaskSyncMarkdownLinks; - var encodedTarget = markdownLink.getAttribute('data-link-target'); - if (encodedTarget && markdownLinksApi && typeof markdownLinksApi.toWebviewMessage === 'function') { - var linkMessage = markdownLinksApi.toWebviewMessage(encodedTarget); - if (linkMessage) { - vscode.postMessage(linkMessage); - } - } - return; - } - - if (dropdownOpen && !e.target.closest('.mode-selector') && !e.target.closest('.mode-dropdown')) closeModeDropdown(); - if (autocompleteVisible && !e.target.closest('.autocomplete-dropdown') && !e.target.closest('#chat-input')) hideAutocomplete(); - if (slashDropdownVisible && !e.target.closest('.slash-dropdown') && !e.target.closest('#chat-input')) hideSlashDropdown(); - }); - - // Remember right-click target so context-menu Copy can resolve the exact clicked message. - document.addEventListener('contextmenu', handleContextMenu); - // Intercept Copy when nothing is selected and copy clicked message text as-is. - document.addEventListener('copy', handleCopy); - - if (queueHeader) queueHeader.addEventListener('click', handleQueueHeaderClick); - if (historyModalClose) historyModalClose.addEventListener('click', closeHistoryModal); - if (historyModalClearAll) historyModalClearAll.addEventListener('click', clearAllPersistedHistory); - if (historyModalOverlay) { - historyModalOverlay.addEventListener('click', function (e) { - if (e.target === historyModalOverlay) closeHistoryModal(); - }); - } - // Edit mode button events - if (editCancelBtn) editCancelBtn.addEventListener('click', cancelEditMode); - if (editConfirmBtn) editConfirmBtn.addEventListener('click', confirmEditMode); - - // Approval modal button events - if (approvalContinueBtn) approvalContinueBtn.addEventListener('click', handleApprovalContinue); - if (approvalNoBtn) approvalNoBtn.addEventListener('click', handleApprovalNo); - - // Settings modal events - if (settingsModalClose) settingsModalClose.addEventListener('click', closeSettingsModal); - if (settingsModalOverlay) { - settingsModalOverlay.addEventListener('click', function (e) { - if (e.target === settingsModalOverlay) closeSettingsModal(); - }); - } - if (soundToggle) { - soundToggle.addEventListener('click', toggleSoundSetting); - soundToggle.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleSoundSetting(); - } - }); - } - if (interactiveApprovalToggle) { - interactiveApprovalToggle.addEventListener('click', toggleInteractiveApprovalSetting); - interactiveApprovalToggle.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleInteractiveApprovalSetting(); - } - }); - } - if (sendShortcutToggle) { - sendShortcutToggle.addEventListener('click', toggleSendWithCtrlEnterSetting); - sendShortcutToggle.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleSendWithCtrlEnterSetting(); - } - }); - } - if (autopilotToggle) { - autopilotToggle.addEventListener('click', toggleAutopilotSetting); - autopilotToggle.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleAutopilotSetting(); - } - }); - } - // Autopilot prompts list event listeners - if (autopilotAddBtn) { - autopilotAddBtn.addEventListener('click', showAddAutopilotPromptForm); - } - if (saveAutopilotPromptBtn) { - saveAutopilotPromptBtn.addEventListener('click', saveAutopilotPrompt); - } - if (cancelAutopilotPromptBtn) { - cancelAutopilotPromptBtn.addEventListener('click', hideAddAutopilotPromptForm); - } - if (autopilotPromptsList) { - autopilotPromptsList.addEventListener('click', handleAutopilotPromptsListClick); - // Drag and drop for reordering - autopilotPromptsList.addEventListener('dragstart', handleAutopilotDragStart); - autopilotPromptsList.addEventListener('dragover', handleAutopilotDragOver); - autopilotPromptsList.addEventListener('dragend', handleAutopilotDragEnd); - autopilotPromptsList.addEventListener('drop', handleAutopilotDrop); - } - if (responseTimeoutSelect) { - responseTimeoutSelect.addEventListener('change', handleResponseTimeoutChange); - } - if (sessionWarningHoursSelect) { - sessionWarningHoursSelect.addEventListener('change', handleSessionWarningHoursChange); - } - if (maxAutoResponsesInput) { - maxAutoResponsesInput.addEventListener('change', handleMaxAutoResponsesChange); - maxAutoResponsesInput.addEventListener('blur', handleMaxAutoResponsesChange); - } - if (humanDelayToggle) { - humanDelayToggle.addEventListener('click', toggleHumanDelaySetting); - humanDelayToggle.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleHumanDelaySetting(); - } - }); - } - if (humanDelayMinInput) { - humanDelayMinInput.addEventListener('change', handleHumanDelayMinChange); - humanDelayMinInput.addEventListener('blur', handleHumanDelayMinChange); - } - if (humanDelayMaxInput) { - humanDelayMaxInput.addEventListener('change', handleHumanDelayMaxChange); - humanDelayMaxInput.addEventListener('blur', handleHumanDelayMaxChange); - } - if (addPromptBtn) addPromptBtn.addEventListener('click', showAddPromptForm); - // Add prompt form events (deferred - bind after modal created) - var cancelPromptBtn = document.getElementById('cancel-prompt-btn'); - var savePromptBtn = document.getElementById('save-prompt-btn'); - if (cancelPromptBtn) cancelPromptBtn.addEventListener('click', hideAddPromptForm); - if (savePromptBtn) savePromptBtn.addEventListener('click', saveNewPrompt); - - window.addEventListener('message', handleExtensionMessage); - } - - function openHistoryModal() { - if (!historyModalOverlay) return; - // Request persisted history from extension - vscode.postMessage({ type: 'openHistoryModal' }); - historyModalOverlay.classList.remove('hidden'); - } - - function closeHistoryModal() { - if (!historyModalOverlay) return; - historyModalOverlay.classList.add('hidden'); - } - - function clearAllPersistedHistory() { - if (persistedHistory.length === 0) return; - vscode.postMessage({ type: 'clearPersistedHistory' }); - persistedHistory = []; - renderHistoryModal(); - } - - function initCardSelection() { - if (cardVibe) { - cardVibe.addEventListener('click', function (e) { - e.preventDefault(); - e.stopPropagation(); - selectCard('normal', true); - }); - } - if (cardSpec) { - cardSpec.addEventListener('click', function (e) { - e.preventDefault(); - e.stopPropagation(); - selectCard('queue', true); - }); - } - // Don't set default here - wait for updateQueue message from extension - // which contains the persisted enabled state - updateCardSelection(); - } - - function selectCard(card, notify) { - selectedCard = card; - queueEnabled = card === 'queue'; - updateCardSelection(); - updateModeUI(); - updateQueueVisibility(); - - // Only notify extension if user clicked (not on init from persisted state) - if (notify) { - vscode.postMessage({ type: 'toggleQueue', enabled: queueEnabled }); - } - } - - function updateCardSelection() { - // card-vibe = Normal mode, card-spec = Queue mode - if (cardVibe) cardVibe.classList.toggle('selected', !queueEnabled); - if (cardSpec) cardSpec.classList.toggle('selected', queueEnabled); - } - - function autoResizeTextarea() { - if (!chatInput) return; - chatInput.style.height = 'auto'; - chatInput.style.height = Math.min(chatInput.scrollHeight, 150) + 'px'; - } - - /** - * Update the input highlighter overlay to show syntax highlighting - * for slash commands (/command) and file references (#file) - */ - function updateInputHighlighter() { - if (!inputHighlighter || !chatInput) return; - - var text = chatInput.value; - if (!text) { - inputHighlighter.innerHTML = ''; - return; - } - - // Build a list of known slash command names for exact matching - var knownSlashNames = reusablePrompts.map(function (p) { return p.name; }); - // Also add any pending stored mappings - var mappings = chatInput._slashPrompts || {}; - Object.keys(mappings).forEach(function (name) { - if (knownSlashNames.indexOf(name) === -1) knownSlashNames.push(name); - }); - - // Escape HTML first - var html = escapeHtml(text); - - // Highlight slash commands - match /word patterns - // Only highlight if it's a known command OR any /word pattern - html = html.replace(/(^|\s)(\/[a-zA-Z0-9_-]+)(\s|$)/g, function (match, before, slash, after) { - var cmdName = slash.substring(1); // Remove the / - // Highlight if it's a known command or if we have prompts defined - if (knownSlashNames.length === 0 || knownSlashNames.indexOf(cmdName) >= 0) { - return before + '' + slash + '' + after; - } - // Still highlight as generic slash command - return before + '' + slash + '' + after; - }); - - // Highlight file references - match #word patterns - html = html.replace(/(^|\s)(#[a-zA-Z0-9_.\/-]+)(\s|$)/g, function (match, before, hash, after) { - return before + '' + hash + '' + after; - }); - - // Don't add trailing space - causes visual artifacts - // html += ' '; - - inputHighlighter.innerHTML = html; - - // Sync scroll position - inputHighlighter.scrollTop = chatInput.scrollTop; - } - - function handleTextareaInput() { - autoResizeTextarea(); - updateInputHighlighter(); - handleAutocomplete(); - handleSlashCommands(); - // Context items (#terminal, #problems) now handled via handleAutocomplete() - syncAttachmentsWithText(); - updateSendButtonState(); - // Persist input value so it survives sidebar tab switches - saveWebviewState(); - } - - function updateSendButtonState() { - if (!sendBtn || !chatInput) return; - var hasText = chatInput.value.trim().length > 0; - sendBtn.classList.toggle('has-text', hasText); - } - - function handleTextareaKeydown(e) { - // Handle approval modal keyboard shortcuts when visible - if (isApprovalQuestion && approvalModal && !approvalModal.classList.contains('hidden')) { - // Enter sends "Continue" when approval modal is visible and input is empty - if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - var inputText = chatInput ? chatInput.value.trim() : ''; - if (!inputText) { - e.preventDefault(); - handleApprovalContinue(); - return; - } - // If there's text, fall through to normal send behavior - } - // Escape dismisses approval modal - if (e.key === 'Escape') { - e.preventDefault(); - handleApprovalNo(); - return; - } - } - - // Handle edit mode keyboard shortcuts - if (editingPromptId) { - if (e.key === 'Escape') { - e.preventDefault(); - cancelEditMode(); - return; - } - if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - confirmEditMode(); - return; - } - // Allow other keys in edit mode - return; - } - - // Handle slash command dropdown navigation - if (slashDropdownVisible) { - if (e.key === 'ArrowDown') { e.preventDefault(); if (selectedSlashIndex < slashResults.length - 1) { selectedSlashIndex++; updateSlashSelection(); } return; } - if (e.key === 'ArrowUp') { e.preventDefault(); if (selectedSlashIndex > 0) { selectedSlashIndex--; updateSlashSelection(); } return; } - if ((e.key === 'Enter' || e.key === 'Tab') && selectedSlashIndex >= 0) { e.preventDefault(); selectSlashItem(selectedSlashIndex); return; } - if (e.key === 'Escape') { e.preventDefault(); hideSlashDropdown(); return; } - } - - if (autocompleteVisible) { - if (e.key === 'ArrowDown') { e.preventDefault(); if (selectedAutocompleteIndex < autocompleteResults.length - 1) { selectedAutocompleteIndex++; updateAutocompleteSelection(); } return; } - if (e.key === 'ArrowUp') { e.preventDefault(); if (selectedAutocompleteIndex > 0) { selectedAutocompleteIndex--; updateAutocompleteSelection(); } return; } - if ((e.key === 'Enter' || e.key === 'Tab') && selectedAutocompleteIndex >= 0) { e.preventDefault(); selectAutocompleteItem(selectedAutocompleteIndex); return; } - if (e.key === 'Escape') { e.preventDefault(); hideAutocomplete(); return; } - } - - // Context dropdown navigation removed - context now uses # via file autocomplete - - var isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey; - var isCtrlOrCmdEnter = e.key === 'Enter' && !e.shiftKey && (e.ctrlKey || e.metaKey); - - if (!sendWithCtrlEnter && isPlainEnter) { - e.preventDefault(); - handleSend(); - return; - } - - if (sendWithCtrlEnter && isCtrlOrCmdEnter) { - e.preventDefault(); - handleSend(); - return; - } - - } - - /** - * Handle send action triggered by VS Code command/keybinding. - * Mirrors Enter behavior while avoiding sends when input is not focused. - */ - function handleSendFromShortcut() { - if (!chatInput || document.activeElement !== chatInput) { - return; - } - - if (isApprovalQuestion && approvalModal && !approvalModal.classList.contains('hidden')) { - var inputText = chatInput.value.trim(); - if (!inputText) { - handleApprovalContinue(); - return; - } - } - - if (editingPromptId) { - confirmEditMode(); - return; - } - - if (slashDropdownVisible && selectedSlashIndex >= 0) { - selectSlashItem(selectedSlashIndex); - return; - } - - if (autocompleteVisible && selectedAutocompleteIndex >= 0) { - selectAutocompleteItem(selectedAutocompleteIndex); - return; - } - - handleSend(); - } - - function handleSend() { - var text = chatInput ? chatInput.value.trim() : ''; - if (!text && currentAttachments.length === 0) { - // If choices are selected and input is empty, send the selected choices - var choicesBar = document.getElementById('choices-bar'); - if (choicesBar && !choicesBar.classList.contains('hidden')) { - var selectedButtons = choicesBar.querySelectorAll('.choice-btn.selected'); - if (selectedButtons.length > 0) { - handleChoicesSend(); - return; - } - } - return; - } - - // Expand slash commands to full prompt text - text = expandSlashCommands(text); - - // Hide approval modal when sending any response - hideApprovalModal(); - - // If processing response (AI working), auto-queue the message - if (isProcessingResponse && text) { - addToQueue(text); - // This reduces friction - user's prompt is in queue, so show them queue mode - if (!queueEnabled) { - queueEnabled = true; - updateModeUI(); - updateQueueVisibility(); - updateCardSelection(); - vscode.postMessage({ type: 'toggleQueue', enabled: true }); - } - if (chatInput) { - chatInput.value = ''; - chatInput.style.height = 'auto'; - updateInputHighlighter(); - } - currentAttachments = []; - updateChipsDisplay(); - updateSendButtonState(); - // Clear persisted state after sending - saveWebviewState(); - return; - } - - if (queueEnabled && text && !pendingToolCall) { - addToQueue(text); - } else { - vscode.postMessage({ type: 'submit', value: text, attachments: currentAttachments }); - } - - if (chatInput) { - chatInput.value = ''; - chatInput.style.height = 'auto'; - updateInputHighlighter(); - } - currentAttachments = []; - updateChipsDisplay(); - updateSendButtonState(); - // Clear persisted state after sending - saveWebviewState(); - } - - function handleAttach() { vscode.postMessage({ type: 'addAttachment' }); } - - function toggleModeDropdown(e) { - e.stopPropagation(); - if (dropdownOpen) closeModeDropdown(); - else { - dropdownOpen = true; - positionModeDropdown(); - modeDropdown.classList.remove('hidden'); - modeDropdown.classList.add('visible'); - } - } - - function positionModeDropdown() { - if (!modeDropdown || !modeBtn) return; - var rect = modeBtn.getBoundingClientRect(); - modeDropdown.style.bottom = (window.innerHeight - rect.top + 4) + 'px'; - modeDropdown.style.left = rect.left + 'px'; - } - - function closeModeDropdown() { - dropdownOpen = false; - if (modeDropdown) { - modeDropdown.classList.remove('visible'); - modeDropdown.classList.add('hidden'); - } - } - - function setMode(mode, notify) { - queueEnabled = mode === 'queue'; - updateModeUI(); - updateQueueVisibility(); - updateCardSelection(); - if (notify) vscode.postMessage({ type: 'toggleQueue', enabled: queueEnabled }); - } - - function updateModeUI() { - if (modeLabel) modeLabel.textContent = queueEnabled ? 'Queue' : 'Normal'; - document.querySelectorAll('.mode-option[data-mode]').forEach(function (opt) { - opt.classList.toggle('selected', opt.getAttribute('data-mode') === (queueEnabled ? 'queue' : 'normal')); - }); - } - - function updateQueueVisibility() { - if (!queueSection) return; - // Hide queue section if: not in queue mode OR queue is empty - var shouldHide = !queueEnabled || promptQueue.length === 0; - var wasHidden = queueSection.classList.contains('hidden'); - queueSection.classList.toggle('hidden', shouldHide); - // Only collapse when showing for the FIRST time (was hidden, now visible) - // Don't collapse on subsequent updates to preserve user's expanded state - if (wasHidden && !shouldHide && promptQueue.length > 0) { - queueSection.classList.add('collapsed'); - } - } - - function handleQueueHeaderClick() { - if (queueSection) queueSection.classList.toggle('collapsed'); - } - - function normalizeResponseTimeout(value) { - if (!Number.isFinite(value)) { - return RESPONSE_TIMEOUT_DEFAULT; - } - if (!RESPONSE_TIMEOUT_ALLOWED_VALUES.has(value)) { - return RESPONSE_TIMEOUT_DEFAULT; - } - return value; - } - - function handleExtensionMessage(event) { - var message = event.data; - console.log('[TaskSync Webview] Received message:', message.type, message); - switch (message.type) { - case 'updateQueue': - promptQueue = message.queue || []; - queueEnabled = message.enabled !== false; - renderQueue(); - updateModeUI(); - updateQueueVisibility(); - updateCardSelection(); - // Hide welcome section if we have current session calls - updateWelcomeSectionVisibility(); - break; - case 'toolCallPending': - console.log('[TaskSync Webview] toolCallPending - showing question:', message.prompt?.substring(0, 50)); - showPendingToolCall(message.id, message.prompt, message.isApprovalQuestion, message.choices); - break; - case 'toolCallCompleted': - addToolCallToCurrentSession(message.entry, message.sessionTerminated); - break; - case 'updateCurrentSession': - currentSessionCalls = message.history || []; - renderCurrentSession(); - // Hide welcome section if we have completed tool calls - updateWelcomeSectionVisibility(); - // Auto-scroll to bottom after rendering - scrollToBottom(); - break; - case 'updatePersistedHistory': - persistedHistory = message.history || []; - renderHistoryModal(); - break; - case 'openHistoryModal': - openHistoryModal(); - break; - case 'openSettingsModal': - openSettingsModal(); - break; - case 'updateSettings': - soundEnabled = message.soundEnabled !== false; - interactiveApprovalEnabled = message.interactiveApprovalEnabled !== false; - sendWithCtrlEnter = message.sendWithCtrlEnter === true; - autopilotEnabled = message.autopilotEnabled === true; - autopilotText = typeof message.autopilotText === 'string' ? message.autopilotText : ''; - autopilotPrompts = Array.isArray(message.autopilotPrompts) ? message.autopilotPrompts : []; - reusablePrompts = message.reusablePrompts || []; - responseTimeout = normalizeResponseTimeout(message.responseTimeout); - sessionWarningHours = typeof message.sessionWarningHours === 'number' ? message.sessionWarningHours : 2; - maxConsecutiveAutoResponses = typeof message.maxConsecutiveAutoResponses === 'number' ? message.maxConsecutiveAutoResponses : 5; - humanLikeDelayEnabled = message.humanLikeDelayEnabled !== false; - humanLikeDelayMin = typeof message.humanLikeDelayMin === 'number' ? message.humanLikeDelayMin : 2; - humanLikeDelayMax = typeof message.humanLikeDelayMax === 'number' ? message.humanLikeDelayMax : 6; - updateSoundToggleUI(); - updateInteractiveApprovalToggleUI(); - updateSendWithCtrlEnterToggleUI(); - updateAutopilotToggleUI(); - renderAutopilotPromptsList(); - updateResponseTimeoutUI(); - updateSessionWarningHoursUI(); - updateMaxAutoResponsesUI(); - updateHumanDelayUI(); - renderPromptsList(); - break; - case 'slashCommandResults': - showSlashDropdown(message.prompts || []); - break; - case 'playNotificationSound': - playNotificationSound(); - break; - case 'fileSearchResults': - showAutocomplete(message.files || []); - break; - case 'updateAttachments': - currentAttachments = message.attachments || []; - updateChipsDisplay(); - break; - case 'imageSaved': - if (message.attachment && !currentAttachments.some(function (a) { return a.id === message.attachment.id; })) { - currentAttachments.push(message.attachment); - updateChipsDisplay(); - } - break; - case 'clear': - promptQueue = []; - currentSessionCalls = []; - pendingToolCall = null; - isProcessingResponse = false; - renderQueue(); - renderCurrentSession(); - if (pendingMessage) { - pendingMessage.classList.add('hidden'); - pendingMessage.innerHTML = ''; - } - updateWelcomeSectionVisibility(); - break; - case 'updateSessionTimer': - // Timer is displayed in the view title bar by the extension host - // No webview UI to update - break; - case 'triggerSendFromShortcut': - handleSendFromShortcut(); - break; - } - } - - function showPendingToolCall(id, prompt, isApproval, choices) { - console.log('[TaskSync Webview] showPendingToolCall called with id:', id); - pendingToolCall = { id: id, prompt: prompt }; - isProcessingResponse = false; // AI is now asking, not processing - isApprovalQuestion = isApproval === true; - currentChoices = choices || []; - - if (welcomeSection) { - welcomeSection.classList.add('hidden'); - } - - // Add pending class to disable session switching UI - document.body.classList.add('has-pending-toolcall'); - - // Show AI question as plain text (hide "Working...." since AI asked a question) - if (pendingMessage) { - console.log('[TaskSync Webview] Setting pendingMessage innerHTML...'); - pendingMessage.classList.remove('hidden'); - pendingMessage.innerHTML = '
' + formatMarkdown(prompt) + '
'; - console.log('[TaskSync Webview] pendingMessage.innerHTML set, length:', pendingMessage.innerHTML.length); - } else { - console.error('[TaskSync Webview] pendingMessage element is null!'); - } - - // Re-render current session (without the pending item - it's shown separately) - renderCurrentSession(); - // Render any mermaid diagrams in pending message - renderMermaidDiagrams(); - // Auto-scroll to show the new pending message - scrollToBottom(); - - // Show choice buttons if we have choices, otherwise show approval modal for yes/no questions - // Only show if interactive approval is enabled - if (interactiveApprovalEnabled) { - if (currentChoices.length > 0) { - showChoicesBar(); - } else if (isApprovalQuestion) { - showApprovalModal(); - } else { - hideApprovalModal(); - hideChoicesBar(); - } - } else { - // Interactive approval disabled - just focus input for manual typing - hideApprovalModal(); - hideChoicesBar(); - if (chatInput) { - chatInput.focus(); - } - } - } - - function addToolCallToCurrentSession(entry, sessionTerminated) { - pendingToolCall = null; - - // Remove pending class to re-enable session switching UI - document.body.classList.remove('has-pending-toolcall'); - - // Hide approval modal and choices bar when tool call completes - hideApprovalModal(); - hideChoicesBar(); - - // Update or add entry to current session - var idx = currentSessionCalls.findIndex(function (tc) { return tc.id === entry.id; }); - if (idx >= 0) { - currentSessionCalls[idx] = entry; - } else { - currentSessionCalls.unshift(entry); - } - renderCurrentSession(); - - // Show working indicator after user responds (AI is now processing the response) - isProcessingResponse = true; - if (pendingMessage) { - pendingMessage.classList.remove('hidden'); - // Check if the extension host signaled session termination - if (sessionTerminated) { - isProcessingResponse = false; - pendingMessage.innerHTML = '
' + - 'Session terminated' + - '
'; - var newSessionBtn = document.getElementById('new-session-btn'); - if (newSessionBtn) { - newSessionBtn.addEventListener('click', function () { - vscode.postMessage({ type: 'newSession' }); - }); - } - } else { - pendingMessage.innerHTML = '
Processing your response
'; - } - } - - // Auto-scroll to show the working indicator - scrollToBottom(); - } - - function renderCurrentSession() { - if (!toolHistoryArea) return; - - // Only show COMPLETED calls from current session (pending is shown separately as plain text) - var completedCalls = currentSessionCalls.filter(function (tc) { return tc.status === 'completed'; }); - - if (completedCalls.length === 0) { - toolHistoryArea.innerHTML = ''; - return; - } - - // Reverse to show oldest first (new items stack at bottom) - var sortedCalls = completedCalls.slice().reverse(); - - var cardsHtml = sortedCalls.map(function (tc, index) { - // Get first sentence for title - let CSS handle truncation with ellipsis - var firstSentence = tc.prompt.split(/[.!?]/)[0]; - var truncatedTitle = firstSentence.length > 120 ? firstSentence.substring(0, 120) + '...' : firstSentence; - var queueBadge = tc.isFromQueue ? 'Queue' : ''; - - // Build card HTML - NO X button for current session cards - var isLatest = index === sortedCalls.length - 1; - var cardHtml = '
' + - '
' + - '
' + - '
' + - '
' + - '' + escapeHtml(truncatedTitle) + queueBadge + '' + - '
' + - '
' + - '
' + - '
' + formatMarkdown(tc.prompt) + '
' + - '
' + - '
' + escapeHtml(tc.response) + '
' + - (tc.attachments ? renderAttachmentsHtml(tc.attachments) : '') + - '
' + - '
'; - return cardHtml; - }).join(''); - - toolHistoryArea.innerHTML = cardsHtml; - - // Bind events - only expand/collapse, no remove - toolHistoryArea.querySelectorAll('.tool-call-header').forEach(function (header) { - header.addEventListener('click', function (e) { - var card = header.closest('.tool-call-card'); - if (card) card.classList.toggle('expanded'); - }); - }); - - // Render any mermaid diagrams - renderMermaidDiagrams(); - } - - function renderHistoryModal() { - if (!historyModalList) return; - - if (persistedHistory.length === 0) { - historyModalList.innerHTML = '
No history yet
'; - if (historyModalClearAll) historyModalClearAll.classList.add('hidden'); - return; - } - - if (historyModalClearAll) historyModalClearAll.classList.remove('hidden'); - - // Helper to render tool call card - function renderToolCallCard(tc) { - var firstSentence = tc.prompt.split(/[.!?]/)[0]; - var truncatedTitle = firstSentence.length > 80 ? firstSentence.substring(0, 80) + '...' : firstSentence; - var queueBadge = tc.isFromQueue ? 'Queue' : ''; - - return '
' + - '
' + - '
' + - '
' + - '
' + - '' + escapeHtml(truncatedTitle) + queueBadge + '' + - '
' + - '' + - '
' + - '
' + - '
' + formatMarkdown(tc.prompt) + '
' + - '
' + - '
' + escapeHtml(tc.response) + '
' + - (tc.attachments ? renderAttachmentsHtml(tc.attachments) : '') + - '
' + - '
'; - } - - // Render all history items directly without grouping - var cardsHtml = '
'; - cardsHtml += persistedHistory.map(renderToolCallCard).join(''); - cardsHtml += '
'; - - historyModalList.innerHTML = cardsHtml; - - // Bind expand/collapse events - historyModalList.querySelectorAll('.tool-call-header').forEach(function (header) { - header.addEventListener('click', function (e) { - if (e.target.closest('.tool-call-remove')) return; - var card = header.closest('.tool-call-card'); - if (card) card.classList.toggle('expanded'); - }); - }); - - // Bind remove buttons - historyModalList.querySelectorAll('.tool-call-remove').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.stopPropagation(); - var id = btn.getAttribute('data-id'); - if (id) { - vscode.postMessage({ type: 'removeHistoryItem', callId: id }); - persistedHistory = persistedHistory.filter(function (tc) { return tc.id !== id; }); - renderHistoryModal(); - } - }); - }); - } - - // Constants for security and performance limits - var MARKDOWN_MAX_LENGTH = 100000; // Max markdown input length to prevent ReDoS - var MAX_TABLE_ROWS = 100; // Max table rows to process - - /** - * Process a buffer of table lines into HTML table markup (ReDoS-safe implementation) - * @param {string[]} lines - Array of table row strings - * @param {number} maxRows - Maximum number of rows to process - * @returns {string} HTML table markup or original lines joined - */ - function processTableBuffer(lines, maxRows) { - if (lines.length < 2) return lines.join('\n'); - if (lines.length > maxRows) return lines.join('\n'); // Skip very large tables - - // Check if second line is separator (contains only |, -, :, spaces) - var separatorRegex = /^\|[\s\-:|]+\|$/; - if (!separatorRegex.test(lines[1].trim())) return lines.join('\n'); - - // Parse header - var headerCells = lines[0].split('|').filter(function (c) { return c.trim() !== ''; }); - if (headerCells.length === 0) return lines.join('\n'); // Invalid table - - var headerHtml = '' + headerCells.map(function (c) { - return '' + c.trim() + ''; - }).join('') + ''; - - // Parse data rows (skip separator at index 1) - var bodyHtml = ''; - for (var i = 2; i < lines.length; i++) { - if (!lines[i].trim()) continue; - var cells = lines[i].split('|').filter(function (c) { return c.trim() !== ''; }); - bodyHtml += '' + cells.map(function (c) { - return '' + c.trim() + ''; - }).join('') + ''; - } - - return '' + headerHtml + '' + bodyHtml + '
'; - } - - /** - * Converts markdown lists (ordered/unordered) with indentation-based nesting into HTML. - * Uses 2-space indentation as one nesting level. - * @param {string} text - Escaped markdown text (must already be HTML-escaped by caller) - * @returns {string} Text with markdown lists converted to nested HTML lists - */ - function convertMarkdownLists(text) { - // Allow empty list items (e.g. "- ") to stay closer to markdown behavior. - var listLineRegex = /^\s*(?:[-*]|\d+\.)\s.*$/; - var lines = text.split('\n'); - var output = []; - var listBuffer = []; - - function renderListNode(node) { - var startAttr = (node.type === 'ol' && typeof node.start === 'number' && node.start > 1) - ? ' start="' + node.start + '"' - : ''; - return '<' + node.type + startAttr + '>' + node.items.map(function (item) { - var childrenHtml = item.children.map(renderListNode).join(''); - return '
  • ' + item.text + childrenHtml + '
  • '; - }).join('') + ''; - } - - function processListBuffer(buffer) { - var listItemRegex = /^(\s*)([-*]|\d+\.)\s+(.*)$/; - var rootLists = []; - var stack = []; // [{ type, list, lastItem }] - - buffer.forEach(function (line) { - var match = listItemRegex.exec(line); - if (!match) return; - - var indent = match[1].replace(/\t/g, ' ').length; - var depth = Math.floor(indent / 2); - var marker = match[2]; - var type = (marker === '-' || marker === '*') ? 'ul' : 'ol'; - var text = match[3]; - - while (stack.length > depth + 1) { - stack.pop(); - } - - var entry = stack[depth]; - if (!entry || entry.type !== type) { - var listNode = { - type: type, - items: [], - start: type === 'ol' ? parseInt(marker, 10) : null - }; - - if (depth === 0) { - rootLists.push(listNode); - } else { - var parentEntry = stack[depth - 1]; - if (parentEntry && parentEntry.lastItem) { - parentEntry.lastItem.children.push(listNode); - } else { - // Fallback for malformed indentation without a parent item - rootLists.push(listNode); - } - } - - entry = { type: type, list: listNode, lastItem: null }; - } - - // Keep one stack entry per depth for predictable parent/child handling. - stack = stack.slice(0, depth); - stack[depth] = entry; - - var item = { text: text, children: [] }; - entry.list.items.push(item); - entry.lastItem = item; - stack[depth] = entry; - }); - - return rootLists.map(renderListNode).join(''); - } - - lines.forEach(function (line) { - if (listLineRegex.test(line)) { - listBuffer.push(line); - return; - } - - if (listBuffer.length > 0) { - output.push(processListBuffer(listBuffer)); - listBuffer = []; - } - - output.push(line); - }); - - if (listBuffer.length > 0) { - output.push(processListBuffer(listBuffer)); - } - - return output.join('\n'); - } - - function formatMarkdown(text) { - if (!text) return ''; - - // ReDoS prevention: truncate very long inputs before regex processing - // This prevents exponential backtracking on crafted inputs (OWASP ReDoS mitigation) - if (text.length > MARKDOWN_MAX_LENGTH) { - text = text.substring(0, MARKDOWN_MAX_LENGTH) + '\n... (content truncated for display)'; - } - - // Normalize line endings (Windows \r\n to \n) - var processedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - // Store code blocks BEFORE escaping HTML to preserve backticks - var codeBlocks = []; - var mermaidBlocks = []; - var inlineCodeSpans = []; - - // Extract mermaid blocks first (before HTML escaping) - // Match ```mermaid followed by newline or just content - processedText = processedText.replace(/```mermaid\s*\n([\s\S]*?)```/g, function (match, code) { - var index = mermaidBlocks.length; - mermaidBlocks.push(code.trim()); - return '%%MERMAID' + index + '%%'; - }); - - // Extract other code blocks (before HTML escaping) - // Match ```lang or just ``` followed by optional newline - processedText = processedText.replace(/```(\w*)\s*\n?([\s\S]*?)```/g, function (match, lang, code) { - var index = codeBlocks.length; - codeBlocks.push({ lang: lang || '', code: code.trim() }); - return '%%CODEBLOCK' + index + '%%'; - }); - - // Extract inline code BEFORE escaping and inline emphasis parsing. - // This prevents * and _ inside `code` from being interpreted as markdown emphasis. - processedText = processedText.replace(/`([^`\n]+)`/g, function (match, code) { - var index = inlineCodeSpans.length; - inlineCodeSpans.push(code); - return '%%INLINECODE' + index + '%%'; - }); - - // Now escape HTML on the remaining text - var html = escapeHtml(processedText); - - // Headers (## Header) - must be at start of line - html = html.replace(/^######\s+(.+)$/gm, '
    $1
    '); - html = html.replace(/^#####\s+(.+)$/gm, '
    $1
    '); - html = html.replace(/^####\s+(.+)$/gm, '

    $1

    '); - html = html.replace(/^###\s+(.+)$/gm, '

    $1

    '); - html = html.replace(/^##\s+(.+)$/gm, '

    $1

    '); - html = html.replace(/^#\s+(.+)$/gm, '

    $1

    '); - - // Horizontal rules (--- or ***) - html = html.replace(/^---+$/gm, '
    '); - html = html.replace(/^\*\*\*+$/gm, '
    '); - - // Blockquotes (> text) - simple single-line support - html = html.replace(/^>\s*(.*)$/gm, '
    $1
    '); - // Merge consecutive blockquotes - html = html.replace(/<\/blockquote>\n
    /g, '\n'); - - // Lists (ordered/unordered, including nested indentation) - // Security contract: html is already escaped above; list conversion must keep item text as-is. - html = convertMarkdownLists(html); - - // Markdown tables - SAFE approach to prevent ReDoS - // Instead of using nested quantifiers with regex (which can cause exponential backtracking), - // we use a line-by-line processing approach for safety - var tableLines = html.split('\n'); - var processedLines = []; - var tableBuffer = []; - var inTable = false; - - for (var lineIdx = 0; lineIdx < tableLines.length; lineIdx++) { - var line = tableLines[lineIdx]; - // Check if line looks like a table row (starts and ends with |) - var isTableRow = /^\|.+\|$/.test(line.trim()); - - if (isTableRow) { - tableBuffer.push(line); - inTable = true; - } else { - if (inTable && tableBuffer.length >= 2) { - // Process accumulated table buffer - var tableHtml = processTableBuffer(tableBuffer, MAX_TABLE_ROWS); - processedLines.push(tableHtml); - } - tableBuffer = []; - inTable = false; - processedLines.push(line); - } - } - // Handle table at end of content - if (inTable && tableBuffer.length >= 2) { - processedLines.push(processTableBuffer(tableBuffer, MAX_TABLE_ROWS)); - } - html = processedLines.join('\n'); - - // Tokenize markdown links before emphasis parsing so link targets are not mutated by markdown transforms. - var markdownLinksApi = window.TaskSyncMarkdownLinks; - var tokenizedLinks = null; - if (markdownLinksApi && typeof markdownLinksApi.tokenizeMarkdownLinks === 'function') { - tokenizedLinks = markdownLinksApi.tokenizeMarkdownLinks(html); - html = tokenizedLinks.text; - } - - // Bold (**text** or __text__) - html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); - html = html.replace(/__([^_]+)__/g, '$1'); - - // Strikethrough (~~text~~) - html = html.replace(/~~([^~]+)~~/g, '$1'); - - // Italic (*text* or _text_) - // For *text*: require non-word boundaries around delimiters and alnum at content edges. - // This avoids false-positive matches in plain prose (e.g. regex snippets, list-marker-like asterisks). - html = html.replace(/(^|[^\p{L}\p{N}_*])\*([\p{L}\p{N}](?:[^*\n]*?[\p{L}\p{N}])?)\*(?=[^\p{L}\p{N}_*]|$)/gu, '$1$2'); - // For _text_: require non-word boundaries (Unicode-aware) around underscore markers - // This keeps punctuation-adjacent emphasis working while avoiding snake_case matches - html = html.replace(/(^|[^\p{L}\p{N}_])_([^_\s](?:[^_]*[^_\s])?)_(?=[^\p{L}\p{N}_]|$)/gu, '$1$2'); - - // Restore tokenized markdown links after emphasis parsing. - if (tokenizedLinks && markdownLinksApi && typeof markdownLinksApi.restoreTokenizedLinks === 'function') { - html = markdownLinksApi.restoreTokenizedLinks(html, tokenizedLinks.links); - } else if (markdownLinksApi && typeof markdownLinksApi.convertMarkdownLinks === 'function') { - html = markdownLinksApi.convertMarkdownLinks(html); - } - - // Restore inline code after emphasis parsing so markdown markers inside code stay literal. - inlineCodeSpans.forEach(function (code, index) { - var escapedCode = escapeHtml(code); - var replacement = '' + escapedCode + ''; - html = html.replace('%%INLINECODE' + index + '%%', replacement); - }); - - // Line breaks - but collapse multiple consecutive breaks - // Don't add
    after block elements - html = html.replace(/\n{3,}/g, '\n\n'); - html = html.replace(/(<\/h[1-6]>|<\/ul>|<\/ol>|<\/blockquote>|
    )\n/g, '$1'); - html = html.replace(/\n/g, '
    '); - - // Restore code blocks - codeBlocks.forEach(function (block, index) { - var langAttr = block.lang ? ' data-lang="' + block.lang + '"' : ''; - var escapedCode = escapeHtml(block.code); - var replacement = '
    ' + escapedCode + '
    '; - html = html.replace('%%CODEBLOCK' + index + '%%', replacement); - }); - - // Restore mermaid blocks as diagrams - mermaidBlocks.forEach(function (code, index) { - var mermaidId = 'mermaid-' + Date.now() + '-' + index + '-' + Math.random().toString(36).substr(2, 9); - var replacement = '
    ' + escapeHtml(code) + '
    '; - html = html.replace('%%MERMAID' + index + '%%', replacement); - }); - - // Clean up excessive
    around block elements - html = html.replace(/(
    )+(' + escapeHtml(code) + ''; - container.classList.add('rendered', 'error'); - }); - } catch (err) { - mermaidDiv.innerHTML = '
    ' + escapeHtml(code) + '
    '; - container.classList.add('rendered', 'error'); - } - }); - }); - } - - /** - * Update welcome section visibility based on current session state - * Hide welcome when there are completed tool calls or a pending call - */ - function updateWelcomeSectionVisibility() { - if (!welcomeSection) return; - var hasCompletedCalls = currentSessionCalls.some(function (tc) { return tc.status === 'completed'; }); - var hasPendingMessage = pendingMessage && !pendingMessage.classList.contains('hidden'); - var shouldHide = hasCompletedCalls || pendingToolCall !== null || hasPendingMessage; - welcomeSection.classList.toggle('hidden', shouldHide); - } - - /** - * Auto-scroll chat container to bottom - */ - function scrollToBottom() { - if (!chatContainer) return; - // Use requestAnimationFrame to ensure DOM is updated before scrolling - requestAnimationFrame(function () { - chatContainer.scrollTop = chatContainer.scrollHeight; - }); - } - - function addToQueue(prompt) { - if (!prompt || !prompt.trim()) return; - var id = 'q_' + Date.now() + '_' + Math.random().toString(36).substring(2, 11); - // Store attachments with the queue item - var attachmentsToStore = currentAttachments.length > 0 ? currentAttachments.slice() : undefined; - promptQueue.push({ id: id, prompt: prompt.trim(), attachments: attachmentsToStore }); - renderQueue(); - // Expand queue section when adding items so user can see what was added - if (queueSection) queueSection.classList.remove('collapsed'); - // Send to backend with attachments - vscode.postMessage({ type: 'addQueuePrompt', prompt: prompt.trim(), id: id, attachments: attachmentsToStore || [] }); - // Clear attachments after adding to queue (they're now stored with the queue item) - currentAttachments = []; - updateChipsDisplay(); - } - - function removeFromQueue(id) { - promptQueue = promptQueue.filter(function (item) { return item.id !== id; }); - renderQueue(); - vscode.postMessage({ type: 'removeQueuePrompt', promptId: id }); - } - - function renderQueue() { - if (!queueList) return; - if (queueCount) queueCount.textContent = promptQueue.length; - - // Update visibility based on queue state - updateQueueVisibility(); - - if (promptQueue.length === 0) { - queueList.innerHTML = '
    No prompts in queue
    '; - return; - } - - queueList.innerHTML = promptQueue.map(function (item, index) { - var bulletClass = index === 0 ? 'active' : 'pending'; - var truncatedPrompt = item.prompt.length > 80 ? item.prompt.substring(0, 80) + '...' : item.prompt; - // Show attachment indicator if this queue item has attachments - var attachmentBadge = (item.attachments && item.attachments.length > 0) - ? '' - : ''; - return '
    ' + - '' + - '' + (index + 1) + '. ' + escapeHtml(truncatedPrompt) + '' + - attachmentBadge + - '
    ' + - '' + - '' + - '
    '; - }).join(''); - - queueList.querySelectorAll('.remove-btn').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.stopPropagation(); - var id = btn.getAttribute('data-id'); - if (id) removeFromQueue(id); - }); - }); - - queueList.querySelectorAll('.edit-btn').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.stopPropagation(); - var id = btn.getAttribute('data-id'); - if (id) startEditPrompt(id); - }); - }); - - bindDragAndDrop(); - bindKeyboardNavigation(); - } - - function startEditPrompt(id) { - // Cancel any existing edit first - if (editingPromptId && editingPromptId !== id) { - cancelEditMode(); - } - - var item = promptQueue.find(function (p) { return p.id === id; }); - if (!item) return; - - // Save current state - editingPromptId = id; - editingOriginalPrompt = item.prompt; - savedInputValue = chatInput ? chatInput.value : ''; - - // Mark queue item as being edited - var queueItem = queueList.querySelector('.queue-item[data-id="' + id + '"]'); - if (queueItem) { - queueItem.classList.add('editing'); - } - - // Switch to edit mode UI - enterEditMode(item.prompt); - } - - function enterEditMode(promptText) { - // Hide normal actions, show edit actions - if (actionsLeft) actionsLeft.classList.add('hidden'); - if (sendBtn) sendBtn.classList.add('hidden'); - if (editActionsContainer) editActionsContainer.classList.remove('hidden'); - - // Mark input container as in edit mode - if (inputContainer) { - inputContainer.classList.add('edit-mode'); - inputContainer.setAttribute('aria-label', 'Editing queue prompt'); - } - - // Set input value to the prompt being edited - if (chatInput) { - chatInput.value = promptText; - chatInput.setAttribute('aria-label', 'Edit prompt text. Press Enter to confirm, Escape to cancel.'); - chatInput.focus(); - // Move cursor to end - chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length); - autoResizeTextarea(); - } - } - - function exitEditMode() { - // Show normal actions, hide edit actions - if (actionsLeft) actionsLeft.classList.remove('hidden'); - if (sendBtn) sendBtn.classList.remove('hidden'); - if (editActionsContainer) editActionsContainer.classList.add('hidden'); - - // Remove edit mode class from input container - if (inputContainer) { - inputContainer.classList.remove('edit-mode'); - inputContainer.removeAttribute('aria-label'); - } - - // Remove editing class from queue item - if (queueList) { - var editingItem = queueList.querySelector('.queue-item.editing'); - if (editingItem) editingItem.classList.remove('editing'); - } - - // Restore original input value and accessibility - if (chatInput) { - chatInput.value = savedInputValue; - chatInput.setAttribute('aria-label', 'Message input'); - autoResizeTextarea(); - } - - // Reset edit state - editingPromptId = null; - editingOriginalPrompt = null; - savedInputValue = ''; - } - - function confirmEditMode() { - if (!editingPromptId) return; - - var newValue = chatInput ? chatInput.value.trim() : ''; - - if (!newValue) { - // If empty, remove the prompt - removeFromQueue(editingPromptId); - } else if (newValue !== editingOriginalPrompt) { - // Update the prompt - var item = promptQueue.find(function (p) { return p.id === editingPromptId; }); - if (item) { - item.prompt = newValue; - vscode.postMessage({ type: 'editQueuePrompt', promptId: editingPromptId, newPrompt: newValue }); - } - } - - // Clear saved input - we don't want to restore old value after editing - savedInputValue = ''; - - exitEditMode(); - renderQueue(); - } - - function cancelEditMode() { - exitEditMode(); - renderQueue(); - } - - /** - * Handle "accept" button click in approval modal - * Sends "yes" as the response - */ - function handleApprovalContinue() { - if (!pendingToolCall) return; - - // Hide approval modal - hideApprovalModal(); - - // Send affirmative response - vscode.postMessage({ type: 'submit', value: 'yes', attachments: [] }); - if (chatInput) { - chatInput.value = ''; - chatInput.style.height = 'auto'; - updateInputHighlighter(); - } - currentAttachments = []; - updateChipsDisplay(); - updateSendButtonState(); - saveWebviewState(); - } - - /** - * Handle "No" button click in approval modal - * Dismisses modal and focuses input for custom response - */ - function handleApprovalNo() { - // Hide approval modal but keep pending state - hideApprovalModal(); - - // Focus input for custom response - if (chatInput) { - chatInput.focus(); - // Optionally pre-fill with "No, " to help user - if (!chatInput.value.trim()) { - chatInput.value = 'No, '; - chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length); - } - autoResizeTextarea(); - updateInputHighlighter(); - updateSendButtonState(); - saveWebviewState(); - } - } - - /** - * Show approval modal - */ - function showApprovalModal() { - if (!approvalModal) return; - approvalModal.classList.remove('hidden'); - // Focus chat input instead of Yes button to prevent accidental Enter approvals - // User can still click Yes/No or use keyboard navigation - if (chatInput) { - chatInput.focus(); - } - } - - /** - * Hide approval modal - */ - function hideApprovalModal() { - if (!approvalModal) return; - approvalModal.classList.add('hidden'); - isApprovalQuestion = false; - } - - /** - * Show choices bar with toggleable multi-select buttons - */ - function showChoicesBar() { - // Hide approval modal first - hideApprovalModal(); - - // Create or get choices bar - var choicesBar = document.getElementById('choices-bar'); - if (!choicesBar) { - choicesBar = document.createElement('div'); - choicesBar.className = 'choices-bar'; - choicesBar.id = 'choices-bar'; - choicesBar.setAttribute('role', 'toolbar'); - choicesBar.setAttribute('aria-label', 'Quick choice options'); - - // Insert at top of input-wrapper - var inputWrapper = document.getElementById('input-wrapper'); - if (inputWrapper) { - inputWrapper.insertBefore(choicesBar, inputWrapper.firstChild); - } - } - - // Build toggleable choice buttons - var buttonsHtml = currentChoices.map(function (choice) { - var shortLabel = choice.shortLabel || choice.value; - var title = choice.label || choice.value; - return ''; - }).join(''); - - choicesBar.innerHTML = 'Choose:' + - '
    ' + buttonsHtml + '
    ' + - '
    ' + - '' + - '' + - '
    '; - - // Bind click events to choice buttons (toggle selection) - choicesBar.querySelectorAll('.choice-btn').forEach(function (btn) { - btn.addEventListener('click', function () { - handleChoiceToggle(btn); - }); - }); - - // Bind 'All' button - var allBtn = choicesBar.querySelector('.choices-all-btn'); - if (allBtn) { - allBtn.addEventListener('click', handleChoicesSelectAll); - } - - // Bind 'Send' button - var choicesSendBtn = choicesBar.querySelector('.choices-send-btn'); - if (choicesSendBtn) { - choicesSendBtn.addEventListener('click', handleChoicesSend); - } - - choicesBar.classList.remove('hidden'); - - // Focus chat input for immediate typing - if (chatInput) { - chatInput.focus(); - } - } - - /** - * Hide choices bar - */ - function hideChoicesBar() { - var choicesBar = document.getElementById('choices-bar'); - if (choicesBar) { - choicesBar.classList.add('hidden'); - } - currentChoices = []; - } - - /** - * Toggle a choice button's selected state - */ - function handleChoiceToggle(btn) { - if (!pendingToolCall) return; - - var isSelected = btn.classList.toggle('selected'); - btn.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); - - updateChoicesSendButton(); - } - - /** - * Toggle all choices selected/deselected - */ - function handleChoicesSelectAll() { - if (!pendingToolCall) return; - - var choicesBar = document.getElementById('choices-bar'); - if (!choicesBar) return; - - var buttons = choicesBar.querySelectorAll('.choice-btn'); - var allSelected = Array.from(buttons).every(function (btn) { - return btn.classList.contains('selected'); - }); - - buttons.forEach(function (btn) { - if (allSelected) { - btn.classList.remove('selected'); - btn.setAttribute('aria-pressed', 'false'); - } else { - btn.classList.add('selected'); - btn.setAttribute('aria-pressed', 'true'); - } - }); - - updateChoicesSendButton(); - } - - /** - * Send all selected choices as a comma-separated response - */ - function handleChoicesSend() { - if (!pendingToolCall) return; - - var choicesBar = document.getElementById('choices-bar'); - if (!choicesBar) return; - - var selectedButtons = choicesBar.querySelectorAll('.choice-btn.selected'); - if (selectedButtons.length === 0) return; - - var values = Array.from(selectedButtons).map(function (btn) { - return btn.getAttribute('data-value'); - }); - var responseValue = values.join(', '); - - // Hide choices bar - hideChoicesBar(); - - // Send the response - vscode.postMessage({ type: 'submit', value: responseValue, attachments: [] }); - if (chatInput) { - chatInput.value = ''; - chatInput.style.height = 'auto'; - updateInputHighlighter(); - } - currentAttachments = []; - updateChipsDisplay(); - updateSendButtonState(); - saveWebviewState(); - } - - /** - * Update the Send button state and All button label based on current selections - */ - function updateChoicesSendButton() { - var choicesBar = document.getElementById('choices-bar'); - if (!choicesBar) return; - - var selectedCount = choicesBar.querySelectorAll('.choice-btn.selected').length; - var totalCount = choicesBar.querySelectorAll('.choice-btn').length; - var choicesSendBtn = choicesBar.querySelector('.choices-send-btn'); - var allBtn = choicesBar.querySelector('.choices-all-btn'); - - if (choicesSendBtn) { - choicesSendBtn.disabled = selectedCount === 0; - choicesSendBtn.textContent = selectedCount > 0 ? 'Send (' + selectedCount + ')' : 'Send'; - } - - if (allBtn) { - var isAllSelected = totalCount > 0 && selectedCount === totalCount; - allBtn.textContent = isAllSelected ? 'None' : 'All'; - var allBtnActionLabel = isAllSelected ? 'Deselect all' : 'Select all'; - allBtn.title = allBtnActionLabel; - allBtn.setAttribute('aria-label', allBtnActionLabel); - } - } - - // ===== SETTINGS MODAL FUNCTIONS ===== - - function openSettingsModal() { - if (!settingsModalOverlay) return; - vscode.postMessage({ type: 'openSettingsModal' }); - settingsModalOverlay.classList.remove('hidden'); - } - - function closeSettingsModal() { - if (!settingsModalOverlay) return; - settingsModalOverlay.classList.add('hidden'); - hideAddPromptForm(); - } - - function toggleSoundSetting() { - soundEnabled = !soundEnabled; - updateSoundToggleUI(); - vscode.postMessage({ type: 'updateSoundSetting', enabled: soundEnabled }); - } - - function updateSoundToggleUI() { - if (!soundToggle) return; - soundToggle.classList.toggle('active', soundEnabled); - soundToggle.setAttribute('aria-checked', soundEnabled ? 'true' : 'false'); - } - - function toggleInteractiveApprovalSetting() { - interactiveApprovalEnabled = !interactiveApprovalEnabled; - updateInteractiveApprovalToggleUI(); - vscode.postMessage({ type: 'updateInteractiveApprovalSetting', enabled: interactiveApprovalEnabled }); - } - - function updateInteractiveApprovalToggleUI() { - if (!interactiveApprovalToggle) return; - interactiveApprovalToggle.classList.toggle('active', interactiveApprovalEnabled); - interactiveApprovalToggle.setAttribute('aria-checked', interactiveApprovalEnabled ? 'true' : 'false'); - } - - function toggleSendWithCtrlEnterSetting() { - sendWithCtrlEnter = !sendWithCtrlEnter; - updateSendWithCtrlEnterToggleUI(); - vscode.postMessage({ type: 'updateSendWithCtrlEnterSetting', enabled: sendWithCtrlEnter }); - } - - function updateSendWithCtrlEnterToggleUI() { - if (!sendShortcutToggle) return; - sendShortcutToggle.classList.toggle('active', sendWithCtrlEnter); - sendShortcutToggle.setAttribute('aria-checked', sendWithCtrlEnter ? 'true' : 'false'); - } - - function toggleAutopilotSetting() { - autopilotEnabled = !autopilotEnabled; - updateAutopilotToggleUI(); - vscode.postMessage({ type: 'updateAutopilotSetting', enabled: autopilotEnabled }); - } - - function updateAutopilotToggleUI() { - if (autopilotToggle) { - autopilotToggle.classList.toggle('active', autopilotEnabled); - autopilotToggle.setAttribute('aria-checked', autopilotEnabled ? 'true' : 'false'); - } - } - - function handleResponseTimeoutChange() { - if (!responseTimeoutSelect) return; - var value = parseInt(responseTimeoutSelect.value, 10); - console.log('[TaskSync] Response timeout changed to:', value); - if (!isNaN(value)) { - responseTimeout = value; - vscode.postMessage({ type: 'updateResponseTimeout', value: value }); - } - } - - function updateResponseTimeoutUI() { - if (!responseTimeoutSelect) return; - responseTimeoutSelect.value = String(responseTimeout); - } - - function handleSessionWarningHoursChange() { - if (!sessionWarningHoursSelect) return; - - var value = parseInt(sessionWarningHoursSelect.value, 10); - if (!isNaN(value) && value >= 0 && value <= 8) { - sessionWarningHours = value; - vscode.postMessage({ type: 'updateSessionWarningHours', value: value }); - } - - sessionWarningHoursSelect.value = String(sessionWarningHours); - } - - function updateSessionWarningHoursUI() { - if (!sessionWarningHoursSelect) return; - sessionWarningHoursSelect.value = String(sessionWarningHours); - } - - function handleMaxAutoResponsesChange() { - if (!maxAutoResponsesInput) return; - var value = parseInt(maxAutoResponsesInput.value, 10); - if (!isNaN(value) && value >= 1 && value <= 50) { - maxConsecutiveAutoResponses = value; - vscode.postMessage({ type: 'updateMaxConsecutiveAutoResponses', value: value }); - } else { - // Reset to valid value - maxAutoResponsesInput.value = maxConsecutiveAutoResponses; - } - } - - function updateMaxAutoResponsesUI() { - if (!maxAutoResponsesInput) return; - maxAutoResponsesInput.value = maxConsecutiveAutoResponses; - } - - /** - * Toggle human-like delay. When enabled, a random delay (jitter) - * between min and max seconds is applied before each auto-response, - * simulating natural human reading and typing time. - */ - function toggleHumanDelaySetting() { - humanLikeDelayEnabled = !humanLikeDelayEnabled; - vscode.postMessage({ type: 'updateHumanDelaySetting', enabled: humanLikeDelayEnabled }); - updateHumanDelayUI(); - } - - /** - * Update minimum delay (seconds). Clamps to valid range [1, max]. - * Sends new value to extension for persistence in VS Code settings. - */ - function handleHumanDelayMinChange() { - if (!humanDelayMinInput) return; - var value = parseInt(humanDelayMinInput.value, 10); - if (!isNaN(value) && value >= 1 && value <= 30) { - // Ensure min <= max - if (value > humanLikeDelayMax) { - value = humanLikeDelayMax; - } - humanLikeDelayMin = value; - vscode.postMessage({ type: 'updateHumanDelayMin', value: value }); - } - humanDelayMinInput.value = humanLikeDelayMin; - } - - /** - * Update maximum delay (seconds). Clamps to valid range [min, 60]. - * Sends new value to extension for persistence in VS Code settings. - */ - function handleHumanDelayMaxChange() { - if (!humanDelayMaxInput) return; - var value = parseInt(humanDelayMaxInput.value, 10); - if (!isNaN(value) && value >= 2 && value <= 60) { - // Ensure max >= min - if (value < humanLikeDelayMin) { - value = humanLikeDelayMin; - } - humanLikeDelayMax = value; - vscode.postMessage({ type: 'updateHumanDelayMax', value: value }); - } - humanDelayMaxInput.value = humanLikeDelayMax; - } - - function updateHumanDelayUI() { - if (humanDelayToggle) { - humanDelayToggle.classList.toggle('active', humanLikeDelayEnabled); - humanDelayToggle.setAttribute('aria-checked', humanLikeDelayEnabled ? 'true' : 'false'); - } - if (humanDelayRangeContainer) { - humanDelayRangeContainer.style.display = humanLikeDelayEnabled ? 'flex' : 'none'; - } - if (humanDelayMinInput) { - humanDelayMinInput.value = humanLikeDelayMin; - } - if (humanDelayMaxInput) { - humanDelayMaxInput.value = humanLikeDelayMax; - } - } - - function showAddPromptForm() { - if (!addPromptForm || !addPromptBtn) return; - addPromptForm.classList.remove('hidden'); - addPromptBtn.classList.add('hidden'); - var nameInput = document.getElementById('prompt-name-input'); - var textInput = document.getElementById('prompt-text-input'); - if (nameInput) { nameInput.value = ''; nameInput.focus(); } - if (textInput) textInput.value = ''; - // Clear edit mode - addPromptForm.removeAttribute('data-editing-id'); - } - - function hideAddPromptForm() { - if (!addPromptForm || !addPromptBtn) return; - addPromptForm.classList.add('hidden'); - addPromptBtn.classList.remove('hidden'); - addPromptForm.removeAttribute('data-editing-id'); - } - - function saveNewPrompt() { - var nameInput = document.getElementById('prompt-name-input'); - var textInput = document.getElementById('prompt-text-input'); - if (!nameInput || !textInput) return; - - var name = nameInput.value.trim(); - var prompt = textInput.value.trim(); - - if (!name || !prompt) { - return; - } - - var editingId = addPromptForm.getAttribute('data-editing-id'); - if (editingId) { - // Editing existing prompt - vscode.postMessage({ type: 'editReusablePrompt', id: editingId, name: name, prompt: prompt }); - } else { - // Adding new prompt - vscode.postMessage({ type: 'addReusablePrompt', name: name, prompt: prompt }); - } - - hideAddPromptForm(); - } - - // ========== Autopilot Prompts Array Functions ========== - - // Track which autopilot prompt is being edited (-1 = adding new, >= 0 = editing index) - var editingAutopilotPromptIndex = -1; - // Track drag state - var draggedAutopilotIndex = -1; - - function renderAutopilotPromptsList() { - if (!autopilotPromptsList) return; - - if (autopilotPrompts.length === 0) { - autopilotPromptsList.innerHTML = '
    No prompts added. Add prompts to cycle through during Autopilot.
    '; - return; - } - - // Render list with drag handles, numbers, edit/delete buttons - autopilotPromptsList.innerHTML = autopilotPrompts.map(function (prompt, index) { - var truncated = prompt.length > 80 ? prompt.substring(0, 80) + '...' : prompt; - var tooltipText = prompt.length > 300 ? prompt.substring(0, 300) + '...' : prompt; - tooltipText = tooltipText.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); - return '
    ' + - '' + - '' + (index + 1) + '.' + - '' + escapeHtml(truncated) + '' + - '
    ' + - '' + - '' + - '
    '; - }).join(''); - } - - function showAddAutopilotPromptForm() { - if (!addAutopilotPromptForm || !autopilotPromptInput) return; - editingAutopilotPromptIndex = -1; - autopilotPromptInput.value = ''; - addAutopilotPromptForm.classList.remove('hidden'); - addAutopilotPromptForm.removeAttribute('data-editing-index'); - autopilotPromptInput.focus(); - } - - function hideAddAutopilotPromptForm() { - if (!addAutopilotPromptForm || !autopilotPromptInput) return; - addAutopilotPromptForm.classList.add('hidden'); - autopilotPromptInput.value = ''; - editingAutopilotPromptIndex = -1; - addAutopilotPromptForm.removeAttribute('data-editing-index'); - } - - function saveAutopilotPrompt() { - if (!autopilotPromptInput) return; - var prompt = autopilotPromptInput.value.trim(); - if (!prompt) return; - - var editingIndex = addAutopilotPromptForm.getAttribute('data-editing-index'); - if (editingIndex !== null) { - // Editing existing - vscode.postMessage({ type: 'editAutopilotPrompt', index: parseInt(editingIndex, 10), prompt: prompt }); - } else { - // Adding new - vscode.postMessage({ type: 'addAutopilotPrompt', prompt: prompt }); - } - hideAddAutopilotPromptForm(); - } - - function handleAutopilotPromptsListClick(e) { - var target = e.target.closest('.prompt-item-btn'); - if (!target) return; - - var index = parseInt(target.getAttribute('data-index'), 10); - if (isNaN(index)) return; - - if (target.classList.contains('edit')) { - editAutopilotPrompt(index); - } else if (target.classList.contains('delete')) { - deleteAutopilotPrompt(index); - } - } - - function editAutopilotPrompt(index) { - if (index < 0 || index >= autopilotPrompts.length) return; - if (!addAutopilotPromptForm || !autopilotPromptInput) return; - - var prompt = autopilotPrompts[index]; - editingAutopilotPromptIndex = index; - autopilotPromptInput.value = prompt; - addAutopilotPromptForm.setAttribute('data-editing-index', index); - addAutopilotPromptForm.classList.remove('hidden'); - autopilotPromptInput.focus(); - } - - function deleteAutopilotPrompt(index) { - if (index < 0 || index >= autopilotPrompts.length) return; - vscode.postMessage({ type: 'removeAutopilotPrompt', index: index }); - } - - function handleAutopilotDragStart(e) { - var item = e.target.closest('.autopilot-prompt-item'); - if (!item) return; - draggedAutopilotIndex = parseInt(item.getAttribute('data-index'), 10); - item.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', draggedAutopilotIndex); - } - - function handleAutopilotDragOver(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - var item = e.target.closest('.autopilot-prompt-item'); - if (!item || !autopilotPromptsList) return; - - // Remove all drag-over classes first - autopilotPromptsList.querySelectorAll('.autopilot-prompt-item').forEach(function (el) { - el.classList.remove('drag-over-top', 'drag-over-bottom'); - }); - - // Determine if we're above or below center of target - var rect = item.getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - if (e.clientY < midY) { - item.classList.add('drag-over-top'); - } else { - item.classList.add('drag-over-bottom'); - } - } - - function handleAutopilotDragEnd(e) { - draggedAutopilotIndex = -1; - if (!autopilotPromptsList) return; - autopilotPromptsList.querySelectorAll('.autopilot-prompt-item').forEach(function (el) { - el.classList.remove('dragging', 'drag-over-top', 'drag-over-bottom'); - }); - } - - function handleAutopilotDrop(e) { - e.preventDefault(); - var item = e.target.closest('.autopilot-prompt-item'); - if (!item || draggedAutopilotIndex < 0) return; - - var toIndex = parseInt(item.getAttribute('data-index'), 10); - if (isNaN(toIndex) || draggedAutopilotIndex === toIndex) { - handleAutopilotDragEnd(e); - return; - } - - // Determine insert position based on where we dropped - var rect = item.getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - var insertBelow = e.clientY >= midY; - - // Calculate actual target index - var targetIndex = toIndex; - if (insertBelow && toIndex < autopilotPrompts.length - 1) { - targetIndex = toIndex + 1; - } - - // Adjust for removal of source - if (draggedAutopilotIndex < targetIndex) { - targetIndex--; - } - - if (draggedAutopilotIndex !== targetIndex) { - vscode.postMessage({ type: 'reorderAutopilotPrompts', fromIndex: draggedAutopilotIndex, toIndex: targetIndex }); - } - - handleAutopilotDragEnd(e); - } - - // ========== End Autopilot Prompts Functions ========== - - function renderPromptsList() { - if (!promptsList) return; - - if (reusablePrompts.length === 0) { - promptsList.innerHTML = ''; - return; - } - - // Compact list - show only name, full prompt on hover via title - promptsList.innerHTML = reusablePrompts.map(function (p) { - // Truncate very long prompts for tooltip to prevent massive tooltips - var tooltipText = p.prompt.length > 300 ? p.prompt.substring(0, 300) + '...' : p.prompt; - // Escape for HTML attribute - tooltipText = tooltipText.replace(/"/g, '"').replace(//g, '>'); - return '
    ' + - '
    ' + - '/' + escapeHtml(p.name) + '' + - '
    ' + - '
    ' + - '' + - '' + - '
    '; - }).join(''); - - // Bind edit/delete events - promptsList.querySelectorAll('.prompt-item-btn.edit').forEach(function (btn) { - btn.addEventListener('click', function () { - var id = btn.getAttribute('data-id'); - editPrompt(id); - }); - }); - - promptsList.querySelectorAll('.prompt-item-btn.delete').forEach(function (btn) { - btn.addEventListener('click', function () { - var id = btn.getAttribute('data-id'); - deletePrompt(id); - }); - }); - } - - function editPrompt(id) { - var prompt = reusablePrompts.find(function (p) { return p.id === id; }); - if (!prompt) return; - - var nameInput = document.getElementById('prompt-name-input'); - var textInput = document.getElementById('prompt-text-input'); - if (!nameInput || !textInput) return; - - // Show form with existing values - addPromptForm.classList.remove('hidden'); - addPromptBtn.classList.add('hidden'); - addPromptForm.setAttribute('data-editing-id', id); - - nameInput.value = prompt.name; - textInput.value = prompt.prompt; - nameInput.focus(); - } - - function deletePrompt(id) { - vscode.postMessage({ type: 'removeReusablePrompt', id: id }); - } - - // ===== SLASH COMMAND FUNCTIONS ===== - - /** - * Expand /commandName patterns to their full prompt text - * Only expands known commands at the start of lines or after whitespace - */ - function expandSlashCommands(text) { - if (!text || reusablePrompts.length === 0) return text; - - // Use stored mappings from selectSlashItem if available - var mappings = chatInput && chatInput._slashPrompts ? chatInput._slashPrompts : {}; - - // Build a regex to match all known prompt names - var promptNames = reusablePrompts.map(function (p) { return p.name; }); - if (Object.keys(mappings).length > 0) { - Object.keys(mappings).forEach(function (name) { - if (promptNames.indexOf(name) === -1) promptNames.push(name); - }); - } - - // Match /promptName at start or after whitespace - var expanded = text; - promptNames.forEach(function (name) { - // Escape special regex chars in name - var escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - var regex = new RegExp('(^|\\s)/' + escapedName + '(?=\\s|$)', 'g'); - var fullPrompt = mappings[name] || (reusablePrompts.find(function (p) { return p.name === name; }) || {}).prompt || ''; - if (fullPrompt) { - expanded = expanded.replace(regex, '$1' + fullPrompt); - } - }); - - // Clear stored mappings after expansion - if (chatInput) chatInput._slashPrompts = {}; - - return expanded.trim(); - } - - function handleSlashCommands() { - if (!chatInput) return; - var value = chatInput.value; - var cursorPos = chatInput.selectionStart; - - // Find slash at start of input or after whitespace - var slashPos = -1; - for (var i = cursorPos - 1; i >= 0; i--) { - if (value[i] === '/') { - // Check if it's at start or after whitespace - if (i === 0 || /\s/.test(value[i - 1])) { - slashPos = i; - } - break; - } - if (/\s/.test(value[i])) break; - } - - if (slashPos >= 0 && reusablePrompts.length > 0) { - var query = value.substring(slashPos + 1, cursorPos); - slashStartPos = slashPos; - if (slashDebounceTimer) clearTimeout(slashDebounceTimer); - slashDebounceTimer = setTimeout(function () { - // Filter locally for instant results - var queryLower = query.toLowerCase(); - var matchingPrompts = reusablePrompts.filter(function (p) { - return p.name.toLowerCase().includes(queryLower) || - p.prompt.toLowerCase().includes(queryLower); - }); - showSlashDropdown(matchingPrompts); - }, 50); - } else if (slashDropdownVisible) { - hideSlashDropdown(); - } - } - - function showSlashDropdown(results) { - if (!slashDropdown || !slashList || !slashEmpty) return; - slashResults = results; - selectedSlashIndex = results.length > 0 ? 0 : -1; - - // Hide file autocomplete if showing slash commands - hideAutocomplete(); - - if (results.length === 0) { - slashList.classList.add('hidden'); - slashEmpty.classList.remove('hidden'); - } else { - slashList.classList.remove('hidden'); - slashEmpty.classList.add('hidden'); - renderSlashList(); - } - slashDropdown.classList.remove('hidden'); - slashDropdownVisible = true; - } - - function hideSlashDropdown() { - if (slashDropdown) slashDropdown.classList.add('hidden'); - slashDropdownVisible = false; - slashResults = []; - selectedSlashIndex = -1; - slashStartPos = -1; - if (slashDebounceTimer) { clearTimeout(slashDebounceTimer); slashDebounceTimer = null; } - } - - function renderSlashList() { - if (!slashList) return; - slashList.innerHTML = slashResults.map(function (p, index) { - var truncatedPrompt = p.prompt.length > 50 ? p.prompt.substring(0, 50) + '...' : p.prompt; - // Prepare tooltip text - escape for HTML attribute - var tooltipText = p.prompt.length > 500 ? p.prompt.substring(0, 500) + '...' : p.prompt; - tooltipText = tooltipText.replace(/"/g, '"').replace(//g, '>'); - return '
    ' + - '' + - '
    ' + - '/' + escapeHtml(p.name) + '' + - '' + escapeHtml(truncatedPrompt) + '' + - '
    '; - }).join(''); - - slashList.querySelectorAll('.slash-item').forEach(function (item) { - item.addEventListener('click', function () { selectSlashItem(parseInt(item.getAttribute('data-index'), 10)); }); - item.addEventListener('mouseenter', function () { selectedSlashIndex = parseInt(item.getAttribute('data-index'), 10); updateSlashSelection(); }); - }); - scrollToSelectedSlashItem(); - } - - function updateSlashSelection() { - if (!slashList) return; - slashList.querySelectorAll('.slash-item').forEach(function (item, index) { - item.classList.toggle('selected', index === selectedSlashIndex); - }); - scrollToSelectedSlashItem(); - } - - function scrollToSelectedSlashItem() { - var selectedItem = slashList ? slashList.querySelector('.slash-item.selected') : null; - if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - - function selectSlashItem(index) { - if (index < 0 || index >= slashResults.length || !chatInput || slashStartPos < 0) return; - var prompt = slashResults[index]; - var value = chatInput.value; - var cursorPos = chatInput.selectionStart; - - // Create a slash tag representation - when sent, we'll expand it to full prompt - // For now, insert /name as text and store the mapping - var slashText = '/' + prompt.name + ' '; - chatInput.value = value.substring(0, slashStartPos) + slashText + value.substring(cursorPos); - var newCursorPos = slashStartPos + slashText.length; - chatInput.setSelectionRange(newCursorPos, newCursorPos); - - // Store the prompt reference for expansion on send - if (!chatInput._slashPrompts) chatInput._slashPrompts = {}; - chatInput._slashPrompts[prompt.name] = prompt.prompt; - - hideSlashDropdown(); - chatInput.focus(); - updateSendButtonState(); - } - - // ===== NOTIFICATION SOUND FUNCTION ===== - - /** - * Unlock audio playback after first user interaction - * Required due to browser autoplay policy - */ - function unlockAudioOnInteraction() { - function unlock() { - if (audioUnlocked) return; - var audio = document.getElementById('notification-sound'); - if (audio) { - // Play and immediately pause to unlock - audio.volume = 0; - var playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(function () { - audio.pause(); - audio.currentTime = 0; - audio.volume = 0.5; - audioUnlocked = true; - console.log('[TaskSync] Audio unlocked successfully'); - }).catch(function () { - // Still locked, will try again on next interaction - }); - } - } - // Remove listeners after first attempt - document.removeEventListener('click', unlock); - document.removeEventListener('keydown', unlock); - } - document.addEventListener('click', unlock, { once: true }); - document.addEventListener('keydown', unlock, { once: true }); - } - - function playNotificationSound() { - console.log('[TaskSync] playNotificationSound called, audioUnlocked:', audioUnlocked); - // Play the preloaded audio element - try { - var audio = document.getElementById('notification-sound'); - console.log('[TaskSync] Audio element found:', !!audio); - if (audio) { - audio.currentTime = 0; // Reset to beginning - audio.volume = 0.5; - console.log('[TaskSync] Attempting to play audio...'); - var playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(function () { - console.log('[TaskSync] Audio playback started successfully'); - }).catch(function (e) { - console.log('[TaskSync] Could not play audio:', e.message); - console.log('[TaskSync] Error name:', e.name); - // If autoplay blocked, show visual feedback - flashNotification(); - }); - } - } else { - console.log('[TaskSync] No audio element found, showing visual notification'); - flashNotification(); - } - } catch (e) { - console.log('[TaskSync] Could not play notification sound:', e); - flashNotification(); - } - } - - function flashNotification() { - // Visual flash when audio fails - var body = document.body; - body.style.transition = 'background-color 0.1s ease'; - var originalBg = body.style.backgroundColor; - body.style.backgroundColor = 'var(--vscode-textLink-foreground, #3794ff)'; - setTimeout(function () { - body.style.backgroundColor = originalBg || ''; - }, 150); - } - - function bindDragAndDrop() { - if (!queueList) return; - queueList.querySelectorAll('.queue-item').forEach(function (item) { - item.addEventListener('dragstart', function (e) { - e.dataTransfer.setData('text/plain', String(parseInt(item.getAttribute('data-index'), 10))); - item.classList.add('dragging'); - }); - item.addEventListener('dragend', function () { item.classList.remove('dragging'); }); - item.addEventListener('dragover', function (e) { e.preventDefault(); item.classList.add('drag-over'); }); - item.addEventListener('dragleave', function () { item.classList.remove('drag-over'); }); - item.addEventListener('drop', function (e) { - e.preventDefault(); - var fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); - var toIndex = parseInt(item.getAttribute('data-index'), 10); - item.classList.remove('drag-over'); - if (fromIndex !== toIndex && !isNaN(fromIndex) && !isNaN(toIndex)) reorderQueue(fromIndex, toIndex); - }); - }); - } - - function bindKeyboardNavigation() { - if (!queueList) return; - var items = queueList.querySelectorAll('.queue-item'); - items.forEach(function (item, index) { - item.addEventListener('keydown', function (e) { - if (e.key === 'ArrowDown' && index < items.length - 1) { e.preventDefault(); items[index + 1].focus(); } - else if (e.key === 'ArrowUp' && index > 0) { e.preventDefault(); items[index - 1].focus(); } - else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); var id = item.getAttribute('data-id'); if (id) removeFromQueue(id); } - }); - }); - } - - function reorderQueue(fromIndex, toIndex) { - var removed = promptQueue.splice(fromIndex, 1)[0]; - promptQueue.splice(toIndex, 0, removed); - renderQueue(); - vscode.postMessage({ type: 'reorderQueue', fromIndex: fromIndex, toIndex: toIndex }); - } - - function handleAutocomplete() { - if (!chatInput) return; - var value = chatInput.value; - var cursorPos = chatInput.selectionStart; - var hashPos = -1; - for (var i = cursorPos - 1; i >= 0; i--) { - if (value[i] === '#') { hashPos = i; break; } - if (value[i] === ' ' || value[i] === '\n') break; - } - if (hashPos >= 0) { - var query = value.substring(hashPos + 1, cursorPos); - autocompleteStartPos = hashPos; - if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - searchDebounceTimer = setTimeout(function () { - vscode.postMessage({ type: 'searchFiles', query: query }); - }, 150); - } else if (autocompleteVisible) { - hideAutocomplete(); - } - } - - function showAutocomplete(results) { - if (!autocompleteDropdown || !autocompleteList || !autocompleteEmpty) return; - autocompleteResults = results; - selectedAutocompleteIndex = results.length > 0 ? 0 : -1; - if (results.length === 0) { - autocompleteList.classList.add('hidden'); - autocompleteEmpty.classList.remove('hidden'); - } else { - autocompleteList.classList.remove('hidden'); - autocompleteEmpty.classList.add('hidden'); - renderAutocompleteList(); - } - autocompleteDropdown.classList.remove('hidden'); - autocompleteVisible = true; - } - - function hideAutocomplete() { - if (autocompleteDropdown) autocompleteDropdown.classList.add('hidden'); - autocompleteVisible = false; - autocompleteResults = []; - selectedAutocompleteIndex = -1; - autocompleteStartPos = -1; - if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); searchDebounceTimer = null; } - } - - function renderAutocompleteList() { - if (!autocompleteList) return; - autocompleteList.innerHTML = autocompleteResults.map(function (file, index) { - return '
    ' + - '' + - '
    ' + escapeHtml(file.name) + '' + - '' + escapeHtml(file.path) + '
    '; - }).join(''); - - autocompleteList.querySelectorAll('.autocomplete-item').forEach(function (item) { - item.addEventListener('click', function () { selectAutocompleteItem(parseInt(item.getAttribute('data-index'), 10)); }); - item.addEventListener('mouseenter', function () { selectedAutocompleteIndex = parseInt(item.getAttribute('data-index'), 10); updateAutocompleteSelection(); }); - }); - scrollToSelectedItem(); - } - - function updateAutocompleteSelection() { - if (!autocompleteList) return; - autocompleteList.querySelectorAll('.autocomplete-item').forEach(function (item, index) { - item.classList.toggle('selected', index === selectedAutocompleteIndex); - }); - scrollToSelectedItem(); - } - - function scrollToSelectedItem() { - var selectedItem = autocompleteList ? autocompleteList.querySelector('.autocomplete-item.selected') : null; - if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - - function selectAutocompleteItem(index) { - if (index < 0 || index >= autocompleteResults.length || !chatInput || autocompleteStartPos < 0) return; - var file = autocompleteResults[index]; - var value = chatInput.value; - var cursorPos = chatInput.selectionStart; - - // Check if this is a context item (#terminal, #problems) - if (file.isContext && file.uri && file.uri.startsWith('context://')) { - // Remove the #query from input - chip will be added - chatInput.value = value.substring(0, autocompleteStartPos) + value.substring(cursorPos); - var newCursorPos = autocompleteStartPos; - chatInput.setSelectionRange(newCursorPos, newCursorPos); - - // Send context reference request to backend - vscode.postMessage({ - type: 'selectContextReference', - contextType: file.name, // 'terminal' or 'problems' - options: undefined - }); - - hideAutocomplete(); - chatInput.focus(); - autoResizeTextarea(); - updateInputHighlighter(); - saveWebviewState(); - updateSendButtonState(); - return; - } - - // Regular file/folder reference - var referenceText = '#' + file.name + ' '; - chatInput.value = value.substring(0, autocompleteStartPos) + referenceText + value.substring(cursorPos); - var newCursorPos = autocompleteStartPos + referenceText.length; - chatInput.setSelectionRange(newCursorPos, newCursorPos); - vscode.postMessage({ type: 'addFileReference', file: file }); - hideAutocomplete(); - chatInput.focus(); - } - - function syncAttachmentsWithText() { - var text = chatInput ? chatInput.value : ''; - var toRemove = []; - currentAttachments.forEach(function (att) { - // Skip temporary attachments (like pasted images) - if (att.isTemporary) return; - // Skip context attachments (#terminal, #problems) - they use context:// URI - if (att.uri && att.uri.startsWith('context://')) return; - // Only sync file references that have isTextReference flag - if (!att.isTextReference) return; - // Check if the #filename reference still exists in text - if (text.indexOf('#' + att.name) === -1) toRemove.push(att.id); - }); - if (toRemove.length > 0) { - toRemove.forEach(function (id) { vscode.postMessage({ type: 'removeAttachment', attachmentId: id }); }); - currentAttachments = currentAttachments.filter(function (a) { return toRemove.indexOf(a.id) === -1; }); - updateChipsDisplay(); - } - } - - function handlePaste(event) { - if (!event.clipboardData) return; - var items = event.clipboardData.items; - for (var i = 0; i < items.length; i++) { - if (items[i].type.indexOf('image/') === 0) { - event.preventDefault(); - var file = items[i].getAsFile(); - if (file) processImageFile(file); - return; - } - } - } - - /** - * Capture latest right-click position for context-menu copy resolution. - */ - function handleContextMenu(event) { - if (!event || !event.target || !event.target.closest) { - lastContextMenuTarget = null; - lastContextMenuTimestamp = 0; - return; - } - - lastContextMenuTarget = event.target; - lastContextMenuTimestamp = Date.now(); - } - - /** - * Override Copy when nothing is selected and context-menu target points to a message. - */ - function handleCopy(event) { - var selection = window.getSelection ? window.getSelection() : null; - if (selection && selection.toString().length > 0) { - return; - } - - if (!lastContextMenuTarget || (Date.now() - lastContextMenuTimestamp) > CONTEXT_MENU_COPY_MAX_AGE_MS) { - return; - } - - var copyText = resolveCopyTextFromTarget(lastContextMenuTarget); - if (!copyText) { - return; - } - - if (event) { - event.preventDefault(); - } - - if (event && event.clipboardData) { - try { - event.clipboardData.setData('text/plain', copyText); - lastContextMenuTarget = null; - lastContextMenuTimestamp = 0; - return; - } catch (error) { - // Fall through to extension host clipboard API fallback. - } - } - - vscode.postMessage({ type: 'copyToClipboard', text: copyText }); - lastContextMenuTarget = null; - lastContextMenuTimestamp = 0; - } - - /** - * Resolve copy payload from the exact message area that was right-clicked. - */ - function resolveCopyTextFromTarget(target) { - if (!target || !target.closest) { - return ''; - } - - var pendingQuestion = target.closest('.pending-ai-question'); - if (pendingQuestion) { - if (pendingToolCall && typeof pendingToolCall.prompt === 'string') { - return pendingToolCall.prompt; - } - return (pendingQuestion.textContent || '').trim(); - } - - var toolCallEntry = resolveToolCallEntryFromTarget(target); - if (!toolCallEntry) { - return ''; - } - - if (target.closest('.tool-call-ai-response')) { - return typeof toolCallEntry.prompt === 'string' ? toolCallEntry.prompt : ''; - } - - if (target.closest('.tool-call-user-response')) { - return typeof toolCallEntry.response === 'string' ? toolCallEntry.response : ''; - } - - if (target.closest('.chips-container')) { - return formatAttachmentsForCopy(toolCallEntry.attachments); - } - - return formatToolCallEntryForCopy(toolCallEntry); - } - - /** - * Resolve a tool call entry by traversing from a DOM target to its card id. - */ - function resolveToolCallEntryFromTarget(target) { - var card = target.closest('.tool-call-card'); - if (!card) { - return null; - } - - return resolveToolCallEntryFromCardId(card.getAttribute('data-id')); - } - - /** - * Find a tool call entry in current session first, then persisted history. - */ - function resolveToolCallEntryFromCardId(cardId) { - if (!cardId) { - return null; - } - - var currentEntry = currentSessionCalls.find(function (tc) { return tc.id === cardId; }); - if (currentEntry) { - return currentEntry; - } - - var persistedEntry = persistedHistory.find(function (tc) { return tc.id === cardId; }); - return persistedEntry || null; - } - - /** - * Compose full card copy output when right-click happened outside a specific message block. - */ - function formatToolCallEntryForCopy(entry) { - if (!entry) { - return ''; - } - - var parts = []; - if (typeof entry.prompt === 'string' && entry.prompt.length > 0) { - parts.push(entry.prompt); - } - if (typeof entry.response === 'string' && entry.response.length > 0) { - parts.push(entry.response); - } - - var attachmentsText = formatAttachmentsForCopy(entry.attachments); - if (attachmentsText) { - parts.push(attachmentsText); - } - - return parts.join('\n\n'); - } - - /** - * Convert attachment list to plain text while preserving stored attachment names. - */ - function formatAttachmentsForCopy(attachments) { - if (!attachments || attachments.length === 0) { - return ''; - } - - return attachments.map(function (att) { - if (att && typeof att.name === 'string' && att.name.length > 0) { - return att.name; - } - return att && typeof att.uri === 'string' ? att.uri : ''; - }).filter(function (value) { - return value.length > 0; - }).join('\n'); - } - - function processImageFile(file) { - var reader = new FileReader(); - reader.onload = function (e) { - if (e.target && e.target.result) vscode.postMessage({ type: 'saveImage', data: e.target.result, mimeType: file.type }); - }; - reader.readAsDataURL(file); - } - - function updateChipsDisplay() { - if (!chipsContainer) return; - if (currentAttachments.length === 0) { - chipsContainer.classList.add('hidden'); - chipsContainer.innerHTML = ''; - } else { - chipsContainer.classList.remove('hidden'); - chipsContainer.innerHTML = currentAttachments.map(function (att) { - var isImage = att.isTemporary || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(att.name); - var iconClass = att.isFolder ? 'folder' : (isImage ? 'file-media' : 'file'); - var displayName = att.isTemporary ? 'Pasted Image' : att.name; - return '
    ' + - '' + - '' + escapeHtml(displayName) + '' + - '
    '; - }).join(''); - - chipsContainer.querySelectorAll('.chip-remove').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.stopPropagation(); - var attId = btn.getAttribute('data-remove'); - if (attId) removeAttachment(attId); - }); - }); - } - // Persist attachments so they survive sidebar tab switches - saveWebviewState(); - } - - function removeAttachment(attachmentId) { - vscode.postMessage({ type: 'removeAttachment', attachmentId: attachmentId }); - currentAttachments = currentAttachments.filter(function (a) { return a.id !== attachmentId; }); - updateChipsDisplay(); - // saveWebviewState() is called in updateChipsDisplay - } - - function escapeHtml(str) { - if (!str) return ''; - var div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; - } - - function renderAttachmentsHtml(attachments) { - if (!attachments || attachments.length === 0) return ''; - var items = attachments.map(function (att) { - var iconClass = 'file'; - if (att.isFolder) iconClass = 'folder'; - else if (att.name && (att.name.endsWith('.png') || att.name.endsWith('.jpg') || att.name.endsWith('.jpeg'))) iconClass = 'file-media'; - else if ((att.uri || '').indexOf('context://terminal') !== -1) iconClass = 'terminal'; - else if ((att.uri || '').indexOf('context://problems') !== -1) iconClass = 'error'; - - return '
    ' + - '' + - '' + escapeHtml(att.name) + '' + - '
    '; - }).join(''); - - return '
    ' + items + '
    '; - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -}()); +/** + * TaskSync Extension - Webview Script + * Handles tool call history, prompt queue, attachments, and file autocomplete + * + * Supports both VS Code webview (postMessage) and Remote PWA (WebSocket) modes + * + * Built from src/webview-ui/ — DO NOT EDIT DIRECTLY + */ +(function () { +// ==================== Shared Constants (SSOT) ==================== +// Use shared constants if available (remote mode), otherwise define inline (VS Code mode) +const SESSION_KEYS = + typeof TASKSYNC_SESSION_KEYS !== "undefined" + ? TASKSYNC_SESSION_KEYS + : { + STATE: "taskSyncState", + PIN: "taskSyncPin", + CONNECTED: "taskSyncConnected", + SESSION_TOKEN: "taskSyncSessionToken", + }; + +const MAX_RECONNECT_ATTEMPTS = + typeof TASKSYNC_MAX_RECONNECT_ATTEMPTS !== "undefined" + ? TASKSYNC_MAX_RECONNECT_ATTEMPTS + : 20; + +const MAX_RECONNECT_DELAY_MS = + typeof TASKSYNC_MAX_RECONNECT_DELAY_MS !== "undefined" + ? TASKSYNC_MAX_RECONNECT_DELAY_MS + : 30000; // 30 seconds max reconnect delay + +const getWsProtocol = + typeof getTaskSyncWsProtocol !== "undefined" + ? getTaskSyncWsProtocol + : function () { + return location.protocol === "https:" ? "wss:" : "ws:"; + }; + +const PROCESSING_POLL_INTERVAL_MS = 5000; // Delay before polling server for state after tool call +// ==================== Communication Adapter ==================== +// Provides unified API for VS Code postMessage or WebSocket communication +const isRemoteMode = typeof acquireVsCodeApi === "undefined"; +// Debug mode: enable via localStorage.setItem('TASKSYNC_DEBUG', 'true') +const REMOTE_DEBUG = + isRemoteMode && localStorage.getItem("TASKSYNC_DEBUG") === "true"; +function debugLog(...args) { + if (REMOTE_DEBUG) console.log("[TaskSync Debug]", ...args); +} +let ws = null; +let wsReconnectAttempt = 0; +let wsState = {}; // Persisted state for remote mode +let wsConnecting = false; // Debounce flag to prevent rapid reconnect attempts +let pendingCriticalMessage = null; // Critical message awaiting send (tool responses) +let pendingOutboundMessages = []; // Non-critical messages queued while disconnected +const MAX_PENDING_OUTBOUND_MESSAGES = 100; +const REPLACEABLE_OUTBOUND_TYPES = new Set([ + "toggleQueue", + "toggleAutopilot", + "updateResponseTimeout", + "searchFiles", + "getState", + "chatCancel", +]); +let processingCheckTimer = null; // Timer to poll server when "Working..." is shown +let wsReconnectTimer = null; // Timer for scheduled reconnect + +function queueOutboundMessage(remoteMsg) { + if (!remoteMsg || !remoteMsg.type) return; + + if (REPLACEABLE_OUTBOUND_TYPES.has(remoteMsg.type)) { + for (var i = pendingOutboundMessages.length - 1; i >= 0; i--) { + if (pendingOutboundMessages[i].type === remoteMsg.type) { + pendingOutboundMessages[i] = remoteMsg; + return; + } + } + } + + if (pendingOutboundMessages.length >= MAX_PENDING_OUTBOUND_MESSAGES) { + pendingOutboundMessages.shift(); + } + pendingOutboundMessages.push(remoteMsg); +} + +function flushQueuedOutboundMessages() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + if (pendingOutboundMessages.length > 0) { + debugLog( + "Flushing queued outbound messages:", + pendingOutboundMessages.length, + ); + while ( + pendingOutboundMessages.length > 0 && + ws && + ws.readyState === WebSocket.OPEN + ) { + ws.send(JSON.stringify(pendingOutboundMessages.shift())); + } + } + + if (pendingCriticalMessage && ws && ws.readyState === WebSocket.OPEN) { + if (pendingToolCall && pendingCriticalMessage.id === pendingToolCall.id) { + ws.send(JSON.stringify(pendingCriticalMessage)); + } else if (!pendingToolCall) { + ws.send(JSON.stringify(pendingCriticalMessage)); + } else { + // Stale queued critical message — drop silently + } + pendingCriticalMessage = null; + } +} + +// Create adapter that works in both environments +const vscode = isRemoteMode ? createRemoteAdapter() : acquireVsCodeApi(); + +function createRemoteAdapter() { + debugLog("Creating remote adapter"); + // Load state from sessionStorage for remote mode + try { + const saved = sessionStorage.getItem(SESSION_KEYS.STATE); + if (saved) wsState = JSON.parse(saved); + } catch (e) { + wsState = {}; + } + + return { + postMessage: function (msg) { + debugLog("postMessage called:", msg.type); + const remoteMsg = mapToRemoteMessage(msg); + if (!remoteMsg) return; + + const isCritical = remoteMsg.type === "respond"; + const wsReady = ws && ws.readyState === WebSocket.OPEN; + + if (wsReady) { + ws.send(JSON.stringify(remoteMsg)); + if (isCritical) pendingCriticalMessage = null; + } else if (isCritical) { + pendingCriticalMessage = remoteMsg; + } else { + queueOutboundMessage(remoteMsg); + } + }, + getState: function () { + return wsState; + }, + setState: function (state) { + wsState = state; + try { + sessionStorage.setItem(SESSION_KEYS.STATE, JSON.stringify(state)); + } catch (e) { + console.error("[TaskSync] Failed to save state:", e); + } + }, + }; +} + +function mapToRemoteMessage(msg) { + // Map VS Code webview messages to remote server messages + switch (msg.type) { + case "submit": + // Response to a pending tool call + if (pendingToolCall) { + return { + type: "respond", + id: pendingToolCall.id, + value: msg.value, + attachments: msg.attachments || [], + }; + } + // No pending tool call — add to queue instead (matches VS Code behavior) + return { + type: "addToQueue", + prompt: msg.value, + attachments: msg.attachments || [], + }; + case "addQueuePrompt": + return { + type: "addToQueue", + prompt: msg.prompt, + attachments: msg.attachments || [], + }; + case "removeQueuePrompt": + return { type: "removeFromQueue", id: msg.promptId }; + case "editQueuePrompt": + return { + type: "editQueuePrompt", + promptId: msg.promptId, + newPrompt: msg.newPrompt, + }; + case "reorderQueue": + return { + type: "reorderQueue", + fromIndex: msg.fromIndex, + toIndex: msg.toIndex, + }; + case "clearQueue": + return { type: "clearQueue" }; + case "toggleQueue": + return { type: "toggleQueue", enabled: msg.enabled }; + case "updateAutopilotSetting": + return { type: "toggleAutopilot", enabled: msg.enabled }; + case "updateResponseTimeout": + return { type: "updateResponseTimeout", timeout: msg.value }; + case "newSession": + return { type: "newSession" }; + case "chatMessage": + return { type: "chatMessage", content: msg.content }; + case "chatFollowUp": + return { type: "chatFollowUp", content: msg.content }; + case "chatCancel": + return { type: "chatCancel" }; + case "startSession": + return { type: "startSession", prompt: msg.prompt || "" }; + case "webviewReady": + return { type: "getState" }; + // Messages that don't apply to remote (VS Code specific) + case "openExternal": + // Open external links in new tab in remote mode + if (msg.url) { + window.open(msg.url, "_blank", "noopener,noreferrer"); + } + return null; + case "addAttachment": + case "openLink": + case "openHistoryModal": + case "openSettingsModal": + return null; // Handle locally or ignore + case "searchFiles": + return { type: "searchFiles", query: msg.query }; + // VS Code-only settings/UI messages — not applicable to remote + case "updateSoundSetting": + case "updateInteractiveApprovalSetting": + case "updateSendWithCtrlEnterSetting": + case "updateHumanDelaySetting": + case "updateHumanDelayMin": + case "updateHumanDelayMax": + case "updateSessionWarningHours": + case "updateMaxConsecutiveAutoResponses": + case "addAutopilotPrompt": + case "editAutopilotPrompt": + case "removeAutopilotPrompt": + case "reorderAutopilotPrompts": + case "addReusablePrompt": + case "editReusablePrompt": + case "removeReusablePrompt": + case "updateAutopilotText": + case "searchSlashCommands": + return msg; // Forward settings to server + case "addFileReference": + case "copyToClipboard": + case "saveImage": + case "removeHistoryItem": + case "clearPersistedHistory": + case "openFileLink": + case "searchContext": + case "selectContextReference": + return null; + default: + // Pass through unknown messages + return msg; + } +} + +let serverShutdown = false; // Track if server was intentionally stopped + +// Update connection status indicator in remote header +function updateRemoteConnectionStatus(status, reason) { + let indicator = document.getElementById("remote-connection-status"); + if (indicator) { + indicator.className = "remote-status " + status; + if (status === "connected") { + indicator.title = "Connected"; + } else if (reason === "shutdown") { + indicator.title = "Server stopped"; + } else if (reason === "max-attempts") { + indicator.title = "Connection failed - server unreachable"; + } else { + indicator.title = "Disconnected - reconnecting..."; + } + } +} + +// Initialize WebSocket for remote mode +if (isRemoteMode) { + connectRemoteWebSocket(); + // Cleanup on page unload to prevent reconnection attempts during teardown + window.addEventListener("beforeunload", function () { + clearTimeout(wsReconnectTimer); + clearTimeout(processingCheckTimer); + serverShutdown = true; // Prevent reconnection + }); +} + +function connectRemoteWebSocket() { + if (wsConnecting) return; + wsConnecting = true; + + // Close any existing connection before creating new one + if (ws) { + try { + ws.close(); + } catch (e) { + console.error("[TaskSync] Failed to close WebSocket:", e); + } + ws = null; + } + serverShutdown = false; + + ws = new WebSocket(`${getWsProtocol()}//${location.host}`); + + ws.onopen = function () { + wsConnecting = false; + wsReconnectAttempt = 0; + const sessionToken = + sessionStorage.getItem(SESSION_KEYS.SESSION_TOKEN) || ""; + const pin = sessionStorage.getItem(SESSION_KEYS.PIN) || ""; + ws.send( + JSON.stringify({ type: "auth", pin: pin, sessionToken: sessionToken }), + ); + }; + + ws.onmessage = function (e) { + try { + const msg = JSON.parse(e.data); + debugLog("WS received:", msg.type, msg); + handleRemoteMessage(msg); + } catch (err) { + console.error("[TaskSync Remote] Message error:", err); + } + }; + + ws.onclose = function (event) { + wsConnecting = false; + clearTimeout(processingCheckTimer); + if (serverShutdown) { + updateRemoteConnectionStatus("disconnected", "shutdown"); + // Don't reconnect - server was intentionally stopped + return; + } + // 1013 = Try Again Later (server at capacity) + if (event.code === 1013) { + updateRemoteConnectionStatus( + "disconnected", + "Server at capacity — retry later", + ); + return; + } + updateRemoteConnectionStatus("disconnected"); + scheduleRemoteReconnect(); + }; + + ws.onerror = function () { + wsConnecting = false; + updateRemoteConnectionStatus("disconnected"); + }; +} + +function scheduleRemoteReconnect() { + if (serverShutdown) { + debugLog("Reconnect skipped (server shutdown)"); + return; + } + wsReconnectAttempt++; + debugLog("Scheduling reconnect attempt:", wsReconnectAttempt); + if (wsReconnectAttempt > MAX_RECONNECT_ATTEMPTS) { + updateRemoteConnectionStatus("disconnected", "max-attempts"); + console.error("[TaskSync Remote] Max reconnection attempts reached."); + return; + } + const delay = Math.min( + 1000 * Math.pow(1.5, wsReconnectAttempt), + MAX_RECONNECT_DELAY_MS, + ); + debugLog("Reconnect in", delay, "ms"); + wsReconnectTimer = setTimeout(connectRemoteWebSocket, delay); +} + +function handleRemoteMessage(msg) { + debugLog("Handling message:", msg.type); + switch (msg.type) { + case "serverShutdown": + debugLog("Server shutdown received"); + serverShutdown = true; + updateRemoteConnectionStatus("disconnected", "shutdown"); + if (editingPromptId) exitEditMode(); + // Show user-friendly message + alert( + "Server stopped: " + + (msg.reason || "The remote server has been stopped."), + ); + return; + case "connected": + case "authSuccess": + if ( + msg.protocolVersion !== undefined && + msg.protocolVersion !== TASKSYNC_PROTOCOL_VERSION + ) + console.error( + "[TaskSync Remote] Protocol version mismatch: server=" + + msg.protocolVersion + + " client=" + + TASKSYNC_PROTOCOL_VERSION, + ); + debugLog( + "Auth success, hasState:", + !!msg.state, + "hasSessionToken:", + !!msg.sessionToken, + ); + if (msg.state) applyServerState(msg.state); + if (msg.sessionToken) + sessionStorage.setItem(SESSION_KEYS.SESSION_TOKEN, msg.sessionToken); + updateRemoteConnectionStatus("connected"); + flushQueuedOutboundMessages(); + break; + case "authFailed": + debugLog("Auth failed, redirecting to login"); + sessionStorage.removeItem(SESSION_KEYS.CONNECTED); + sessionStorage.removeItem(SESSION_KEYS.PIN); + sessionStorage.removeItem(SESSION_KEYS.STATE); + sessionStorage.removeItem(SESSION_KEYS.SESSION_TOKEN); + window.location.href = "index.html"; + break; + case "requireAuth": + // Server sends this before auth is processed — handled by onopen auth flow + break; + case "toolCallPending": + if (!msg.data) break; + debugLog( + "toolCallPending:", + msg.data.id, + "isApproval:", + msg.data.isApproval, + ); + clearTimeout(processingCheckTimer); + showPendingToolCall( + msg.data.id, + msg.data.prompt, + msg.data.isApproval, + msg.data.choices, + msg.data.summary, + ); + if (typeof playNotificationSound === "function") playNotificationSound(); + break; + case "toolCallCompleted": + if (!msg.data) break; + debugLog( + "toolCallCompleted:", + msg.data.entry?.id, + "sessionTerminated:", + msg.data.sessionTerminated, + ); + document.body.classList.remove("has-pending-toolcall"); + if (typeof hideApprovalModal === "function") hideApprovalModal(); + if (typeof hideChoicesBar === "function") hideChoicesBar(); + if (msg.data.entry) { + if (!msg.data.entry.status) msg.data.entry.status = "completed"; + currentSessionCalls = currentSessionCalls.filter(function (tc) { + return tc.id !== msg.data.entry.id; + }); + currentSessionCalls = [msg.data.entry, ...currentSessionCalls].slice( + 0, + MAX_DISPLAY_HISTORY, + ); + renderCurrentSession(); + } + pendingToolCall = null; + if (typeof scrollToBottom === "function") scrollToBottom(); + if (msg.data.sessionTerminated) { + isProcessingResponse = false; + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    Session terminated' + + '
    '; + var tBtn = document.getElementById( + "remote-terminated-new-session-btn", + ); + if (tBtn) + tBtn.addEventListener("click", function () { + openNewSessionModal(); + }); + } + } else { + isProcessingResponse = true; + updatePendingUI(); + clearTimeout(processingCheckTimer); + processingCheckTimer = setTimeout(function () { + if ( + isProcessingResponse && + !pendingToolCall && + ws && + ws.readyState === WebSocket.OPEN + ) + ws.send(JSON.stringify({ type: "getState" })); + }, PROCESSING_POLL_INTERVAL_MS); + } + break; + case "queueChanged": + if (!msg.data) break; + // Update queue version for optimistic concurrency control + if (msg.data.queueVersion !== undefined) { + queueVersion = msg.data.queueVersion; + } + promptQueue = msg.data.queue || []; + renderQueue(); + if (typeof updateCardSelection === "function") updateCardSelection(); + updateQueueVisibility(); + break; + case "settingsChanged": + if (!msg.data) break; + debugLog("settingsChanged:", Object.keys(msg.data)); + applySettingsData(msg.data); + break; + case "newSession": + debugLog("newSession received - clearing state"); + clearTimeout(processingCheckTimer); + currentSessionCalls = []; + pendingToolCall = null; + isProcessingResponse = false; + if (chatStreamArea) { + chatStreamArea.innerHTML = ""; + chatStreamArea.classList.add("hidden"); + } + document.body.classList.remove("has-pending-toolcall"); + if (typeof hideApprovalModal === "function") hideApprovalModal(); + if (typeof hideChoicesBar === "function") hideChoicesBar(); + renderCurrentSession(); + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    ' + + ' New session started \u2014 waiting for AI' + + "
    "; + } + if (typeof updateWelcomeSectionVisibility === "function") + updateWelcomeSectionVisibility(); + debugLog("newSession complete - state cleared"); + break; + case "fileSearchResults": + showAutocomplete(msg.files || []); + break; + case "slashCommandResults": + if (typeof showSlashDropdown === "function") + showSlashDropdown(msg.prompts || []); + break; + case "state": + debugLog( + "Full state refresh:", + msg.data ? Object.keys(msg.data) : "no data", + ); + if (msg.data) applyServerState(msg.data); + break; + case "changes": + case "diff": + case "staged": + case "unstaged": + case "stagedAll": + case "discarded": + case "committed": + case "pushed": + case "changesUpdated": + // Git responses handled silently + break; + case "error": + // Error messages can come from broadcast (wrapped in {type, data}) or sendWsError (top-level) + var errMsg = msg.message || (msg.data && msg.data.message); + var errCode = msg.code || (msg.data && msg.data.code); + console.error("[TaskSync Remote] Server error:", errMsg); + if (errMsg === "Not authenticated") { + sessionStorage.removeItem(SESSION_KEYS.CONNECTED); + window.location.href = "index.html"; + } else if ( + errCode === "ALREADY_ANSWERED" || + errCode === "ITEM_NOT_FOUND" || + errCode === "QUEUE_FULL" || + errCode === "INVALID_INPUT" + ) { + alert(errMsg); + } + break; + } +} + +// ——— Server state application (SSOT) ——— + +// Apply settings data from either settingsChanged broadcast or getState response (SSOT) +function applySettingsData(s) { + if (s.autopilotEnabled !== undefined) autopilotEnabled = s.autopilotEnabled; + if (s.queueEnabled !== undefined) { + queueEnabled = s.queueEnabled; + updateQueueVisibility(); + } + if (s.responseTimeout !== undefined) responseTimeout = s.responseTimeout; + if (s.soundEnabled !== undefined) soundEnabled = s.soundEnabled; + if (s.interactiveApprovalEnabled !== undefined) + interactiveApprovalEnabled = s.interactiveApprovalEnabled; + if (s.sendWithCtrlEnter !== undefined) + sendWithCtrlEnter = s.sendWithCtrlEnter; + if (s.sessionWarningHours !== undefined) + sessionWarningHours = s.sessionWarningHours; + if (s.maxConsecutiveAutoResponses !== undefined) + maxConsecutiveAutoResponses = s.maxConsecutiveAutoResponses; + if (s.humanLikeDelayEnabled !== undefined) + humanLikeDelayEnabled = s.humanLikeDelayEnabled; + if (s.humanLikeDelayMin !== undefined) + humanLikeDelayMin = s.humanLikeDelayMin; + if (s.humanLikeDelayMax !== undefined) + humanLikeDelayMax = s.humanLikeDelayMax; + if (s.autopilotPrompts !== undefined) autopilotPrompts = s.autopilotPrompts; + if (s.reusablePrompts !== undefined) reusablePrompts = s.reusablePrompts; + updateModeUI(); + applySettingsToUI(); +} + +// Apply server state (SSOT - single function for all state updates) +function applyServerState(state) { + if (state.queue) { + promptQueue = state.queue; + renderQueue(); + } + if (state.queueVersion !== undefined) { + queueVersion = state.queueVersion; + } + if (state.pending) { + handlePendingToolCall(state.pending); + } else { + // No pending tool call — clear any stale pending state + pendingToolCall = null; + document.body.classList.remove("has-pending-toolcall"); + if (typeof hideApprovalModal === "function") hideApprovalModal(); + if (typeof hideChoicesBar === "function") hideChoicesBar(); + } + // Use server processing flag or pending inference + isProcessingResponse = state.isProcessing ?? state.pending !== null; + if (state.history) { + currentSessionCalls = state.history; + renderCurrentSession(); + } + if (state.settings) applySettingsData(state.settings); + updatePendingUI(); + if (typeof updateCardSelection === "function") updateCardSelection(); + if (typeof updateWelcomeSectionVisibility === "function") + updateWelcomeSectionVisibility(); +} + +function handlePendingToolCall(data) { + debugLog( + "handlePendingToolCall — id:", + data.id, + "hasSummary:", + !!data.summary, + "summaryLength:", + data.summary ? data.summary.length : 0, + "promptLength:", + data.prompt ? data.prompt.length : 0, + ); + if (typeof showPendingToolCall === "function") { + showPendingToolCall( + data.id, + data.prompt, + data.isApproval, + data.choices, + data.summary, + ); + } else { + pendingToolCall = data; + isApprovalQuestion = data.isApproval || false; + currentChoices = (data.choices || []).map(function (c) { + return typeof c === "string" ? { label: c, value: c, shortLabel: c } : c; + }); + isProcessingResponse = false; + updatePendingUI(); + } +} + +let wasProcessing = false; // Track processing→idle transition +function updatePendingUI() { + if (!pendingMessage) return; + + if (pendingToolCall) { + wasProcessing = false; + pendingMessage.classList.remove("hidden"); + let pendingHtml = ""; + if (pendingToolCall.summary) { + debugLog( + "Rendering summary in remote pending view — length:", + pendingToolCall.summary.length, + "preview:", + pendingToolCall.summary.slice(0, 80), + ); + pendingHtml += + '
    ' + + (typeof formatMarkdown === "function" + ? formatMarkdown(pendingToolCall.summary) + : escapeHtml(pendingToolCall.summary)) + + "
    "; + } else { + debugLog("No summary to render in remote pending view"); + } + pendingHtml += + '
    ' + + (typeof formatMarkdown === "function" + ? formatMarkdown(pendingToolCall.prompt || "") + : escapeHtml(pendingToolCall.prompt || "")) + + "
    "; + debugLog("Remote pending HTML set — totalLength:", pendingHtml.length); + pendingMessage.innerHTML = pendingHtml; + } else if (isProcessingResponse) { + wasProcessing = true; + // AI is processing the response — show working indicator + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    Working\u2026
    '; + } else if (wasProcessing && currentSessionCalls.length > 0) { + wasProcessing = false; + // AI was working but stopped without calling askUser — show idle notice + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    Agent finished \u2014 type a message to continue
    '; + } else { + wasProcessing = false; + pendingMessage.classList.add("hidden"); + pendingMessage.innerHTML = ""; + } +} + +/** Refresh all settings toggle/input UI from current state variables. */ +function applySettingsToUI() { + updateSoundToggleUI(); + updateInteractiveApprovalToggleUI(); + updateSendWithCtrlEnterToggleUI(); + updateAutopilotToggleUI(); + updateResponseTimeoutUI(); + updateSessionWarningHoursUI(); + updateMaxAutoResponsesUI(); + updateHumanDelayUI(); + renderAutopilotPromptsList(); + renderPromptsList(); + updateQueueVisibility(); +} + +// ==================== End Communication Adapter ==================== +// Restore persisted state (survives sidebar switch) +const previousState = vscode.getState() || {}; + +// Settings defaults & validation ranges — use shared constants if available (remote mode) +// Keep timeout options aligned with select values to avoid invalid UI state. +const RESPONSE_TIMEOUT_ALLOWED_VALUES = + typeof TASKSYNC_RESPONSE_TIMEOUT_ALLOWED !== "undefined" + ? new Set(TASKSYNC_RESPONSE_TIMEOUT_ALLOWED) + : new Set([ + 0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 150, 180, 210, + 240, + ]); +const RESPONSE_TIMEOUT_DEFAULT = + typeof TASKSYNC_RESPONSE_TIMEOUT_DEFAULT !== "undefined" + ? TASKSYNC_RESPONSE_TIMEOUT_DEFAULT + : 60; +const MAX_DISPLAY_HISTORY = 20; // Client-side display limit (matches MAX_REMOTE_HISTORY_ITEMS) + +const DEFAULT_SESSION_WARNING_HOURS = + typeof TASKSYNC_DEFAULT_SESSION_WARNING_HOURS !== "undefined" + ? TASKSYNC_DEFAULT_SESSION_WARNING_HOURS + : 2; +const SESSION_WARNING_HOURS_MAX = + typeof TASKSYNC_SESSION_WARNING_HOURS_MAX !== "undefined" + ? TASKSYNC_SESSION_WARNING_HOURS_MAX + : 8; +const DEFAULT_MAX_AUTO_RESPONSES = + typeof TASKSYNC_DEFAULT_MAX_AUTO_RESPONSES !== "undefined" + ? TASKSYNC_DEFAULT_MAX_AUTO_RESPONSES + : 5; +const MAX_AUTO_RESPONSES_LIMIT = + typeof TASKSYNC_MAX_AUTO_RESPONSES_LIMIT !== "undefined" + ? TASKSYNC_MAX_AUTO_RESPONSES_LIMIT + : 100; +const DEFAULT_HUMAN_DELAY_MIN = + typeof TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MIN !== "undefined" + ? TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MIN + : 2; +const DEFAULT_HUMAN_DELAY_MAX = + typeof TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MAX !== "undefined" + ? TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MAX + : 6; +const HUMAN_DELAY_MIN_LOWER = + typeof TASKSYNC_HUMAN_DELAY_MIN_LOWER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MIN_LOWER + : 1; +const HUMAN_DELAY_MIN_UPPER = + typeof TASKSYNC_HUMAN_DELAY_MIN_UPPER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MIN_UPPER + : 30; +const HUMAN_DELAY_MAX_LOWER = + typeof TASKSYNC_HUMAN_DELAY_MAX_LOWER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MAX_LOWER + : 2; +const HUMAN_DELAY_MAX_UPPER = + typeof TASKSYNC_HUMAN_DELAY_MAX_UPPER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MAX_UPPER + : 60; + +// State +let promptQueue = []; +let queueVersion = 0; // Optimistic concurrency control for queue operations +let queueEnabled = true; // Default to true (Queue mode ON by default) +let dropdownOpen = false; +let currentAttachments = previousState.attachments || []; // Restore attachments +let selectedCard = "queue"; +let currentSessionCalls = []; // Current session tool calls (shown in chat) +let persistedHistory = []; // Past sessions history (shown in modal) +let lastContextMenuTarget = null; // Tracks where right-click was triggered for copy fallback behavior +let lastContextMenuTimestamp = 0; // Ensures stale right-click targets are not reused for copy +let pendingToolCall = null; +let isProcessingResponse = false; // True when AI is processing user's response +let isApprovalQuestion = false; // True when current pending question is an approval-type question +let currentChoices = []; // Parsed choices from multi-choice questions + +// Settings state (initialized from constants to maintain SSOT) +let soundEnabled = true; +let interactiveApprovalEnabled = true; +let sendWithCtrlEnter = false; +let autopilotEnabled = false; +let autopilotText = ""; +let autopilotPrompts = []; +let responseTimeout = RESPONSE_TIMEOUT_DEFAULT; +let sessionWarningHours = DEFAULT_SESSION_WARNING_HOURS; +let maxConsecutiveAutoResponses = DEFAULT_MAX_AUTO_RESPONSES; + +// Human-like delay: random jitter simulates natural reading/typing time +let humanLikeDelayEnabled = true; +let humanLikeDelayMin = DEFAULT_HUMAN_DELAY_MIN; +let humanLikeDelayMax = DEFAULT_HUMAN_DELAY_MAX; +const CONTEXT_MENU_COPY_MAX_AGE_MS = 30000; + +// Tracks local edits to prevent stale settings overwriting user input mid-typing. +let reusablePrompts = []; +let audioUnlocked = false; // Track if audio playback has been unlocked by user gesture + +// Slash command autocomplete state +let slashDropdownVisible = false; +let slashResults = []; +let selectedSlashIndex = -1; +let slashStartPos = -1; +let slashDebounceTimer = null; + +// Persisted input value (restored from state) +let persistedInputValue = previousState.inputValue || ""; + +// Edit mode state +let editingPromptId = null; +let editingOriginalPrompt = null; +let savedInputValue = ""; // Save input value when entering edit mode + +// Autocomplete state +let autocompleteVisible = false; +let autocompleteResults = []; +let selectedAutocompleteIndex = -1; +let autocompleteStartPos = -1; +let searchDebounceTimer = null; + +// DOM Elements +let chatInput, sendBtn, attachBtn, modeBtn, modeDropdown, modeLabel; +let inputHighlighter; // Overlay for syntax highlighting in input +let queueSection, queueHeader, queueList, queueCount; +let chatContainer, + chipsContainer, + autocompleteDropdown, + autocompleteList, + autocompleteEmpty; +let inputContainer, inputAreaContainer, welcomeSection; +let cardVibe, cardSpec, toolHistoryArea, pendingMessage; +let chatStreamArea; // DOM container for remote user message bubbles +let historyModal, + historyModalOverlay, + historyModalList, + historyModalClose, + historyModalClearAll; + +// Edit mode elements +let actionsLeft, + actionsBar, + editActionsContainer, + editCancelBtn, + editConfirmBtn; +// Approval modal elements +let approvalModal, approvalContinueBtn, approvalNoBtn; +// Slash command elements +let slashDropdown, slashList, slashEmpty; +// Settings modal elements +let settingsModal, settingsModalOverlay, settingsModalClose; +let soundToggle, + interactiveApprovalToggle, + sendShortcutToggle, + autopilotToggle, + promptsList, + addPromptBtn, + addPromptForm; +let autopilotPromptsList, + autopilotAddBtn, + addAutopilotPromptForm, + autopilotPromptInput, + saveAutopilotPromptBtn, + cancelAutopilotPromptBtn; +let responseTimeoutSelect, sessionWarningHoursSelect, maxAutoResponsesInput; +let humanDelayToggle, + humanDelayRangeContainer, + humanDelayMinInput, + humanDelayMaxInput; +function init() { + try { + cacheDOMElements(); + createHistoryModal(); + createEditModeUI(); + createApprovalModal(); + createSettingsModal(); + createNewSessionModal(); + bindEventListeners(); + unlockAudioOnInteraction(); // Enable audio after first user interaction + + // Remote mode: bind header buttons and hide VS Code-only UI + if (isRemoteMode) { + var newSessionBtn = document.getElementById("remote-new-session-btn"); + if (newSessionBtn) + newSessionBtn.addEventListener("click", function (e) { + e.stopPropagation(); + openNewSessionModal(); + }); + var settingsBtn = document.getElementById("remote-settings-btn"); + if (settingsBtn) + settingsBtn.addEventListener("click", function () { + openSettingsModal(); + }); + // Hide attach button (VS Code-only) + var attachBtn = document.getElementById("attach-btn"); + if (attachBtn) attachBtn.style.display = "none"; + } + renderQueue(); + updateModeUI(); + updateQueueVisibility(); + initCardSelection(); + + // Restore persisted input value (when user switches sidebar tabs and comes back) + if (chatInput && persistedInputValue) { + chatInput.value = persistedInputValue; + autoResizeTextarea(); + updateInputHighlighter(); + updateSendButtonState(); + } + + // Restore attachments display + if (currentAttachments.length > 0) { + updateChipsDisplay(); + } + + // Signal to extension that webview is ready to receive messages + // In remote mode, state comes via authSuccess after WebSocket connects — skip webviewReady + if (!isRemoteMode) { + vscode.postMessage({ type: "webviewReady" }); + } + } catch (err) { + console.error("[TaskSync] Init error:", err); + } +} + +/** + * Save webview state to persist across sidebar visibility changes + */ +function saveWebviewState() { + vscode.setState({ + inputValue: chatInput ? chatInput.value : "", + attachments: currentAttachments.filter(function (a) { + return !a.isTemporary; + }), // Don't persist temp images + }); +} + +function cacheDOMElements() { + chatInput = document.getElementById("chat-input"); + inputHighlighter = document.getElementById("input-highlighter"); + sendBtn = document.getElementById("send-btn"); + attachBtn = document.getElementById("attach-btn"); + modeBtn = document.getElementById("mode-btn"); + modeDropdown = document.getElementById("mode-dropdown"); + modeLabel = document.getElementById("mode-label"); + + queueSection = document.getElementById("queue-section"); + queueHeader = document.getElementById("queue-header"); + queueList = document.getElementById("queue-list"); + queueCount = document.getElementById("queue-count"); + chatContainer = document.getElementById("chat-container"); + chipsContainer = document.getElementById("chips-container"); + autocompleteDropdown = document.getElementById("autocomplete-dropdown"); + autocompleteList = document.getElementById("autocomplete-list"); + autocompleteEmpty = document.getElementById("autocomplete-empty"); + inputContainer = document.getElementById("input-container"); + inputAreaContainer = document.getElementById("input-area-container"); + welcomeSection = document.getElementById("welcome-section"); + cardVibe = document.getElementById("card-vibe"); + cardSpec = document.getElementById("card-spec"); + autopilotToggle = document.getElementById("autopilot-toggle"); + toolHistoryArea = document.getElementById("tool-history-area"); + chatStreamArea = document.getElementById("chat-stream-area"); + pendingMessage = document.getElementById("pending-message"); + // Slash command dropdown + slashDropdown = document.getElementById("slash-dropdown"); + slashList = document.getElementById("slash-list"); + slashEmpty = document.getElementById("slash-empty"); + // Get actions bar elements for edit mode + actionsBar = document.querySelector(".actions-bar"); + actionsLeft = document.querySelector(".actions-left"); +} + +function createHistoryModal() { + // Create modal overlay + historyModalOverlay = document.createElement("div"); + historyModalOverlay.className = "history-modal-overlay hidden"; + historyModalOverlay.id = "history-modal-overlay"; + + // Create modal container + historyModal = document.createElement("div"); + historyModal.className = "history-modal"; + historyModal.id = "history-modal"; + historyModal.setAttribute("role", "dialog"); + historyModal.setAttribute("aria-modal", "true"); + historyModal.setAttribute("aria-label", "Session History"); + + // Modal header + let modalHeader = document.createElement("div"); + modalHeader.className = "history-modal-header"; + + let titleSpan = document.createElement("span"); + titleSpan.className = "history-modal-title"; + titleSpan.textContent = "History"; + modalHeader.appendChild(titleSpan); + + // Info text - left aligned after title + let infoSpan = document.createElement("span"); + infoSpan.className = "history-modal-info"; + infoSpan.textContent = + "History is stored in VS Code globalStorage/tool-history.json"; + modalHeader.appendChild(infoSpan); + + // Clear all button (icon only) + historyModalClearAll = document.createElement("button"); + historyModalClearAll.className = "history-modal-clear-btn"; + historyModalClearAll.innerHTML = + ''; + historyModalClearAll.title = "Clear all history"; + modalHeader.appendChild(historyModalClearAll); + + // Close button + historyModalClose = document.createElement("button"); + historyModalClose.className = "history-modal-close-btn"; + historyModalClose.innerHTML = ''; + historyModalClose.title = "Close"; + modalHeader.appendChild(historyModalClose); + + // Modal body (list) + historyModalList = document.createElement("div"); + historyModalList.className = "history-modal-list"; + historyModalList.id = "history-modal-list"; + + // Assemble modal + historyModal.appendChild(modalHeader); + historyModal.appendChild(historyModalList); + historyModalOverlay.appendChild(historyModal); + + // Add to DOM + document.body.appendChild(historyModalOverlay); +} + +function createEditModeUI() { + // Create edit actions container (hidden by default) + editActionsContainer = document.createElement("div"); + editActionsContainer.className = "edit-actions-container hidden"; + editActionsContainer.id = "edit-actions-container"; + + // Edit mode label + let editLabel = document.createElement("span"); + editLabel.className = "edit-mode-label"; + editLabel.textContent = "Editing prompt"; + + // Cancel button (X) + editCancelBtn = document.createElement("button"); + editCancelBtn.className = "icon-btn edit-cancel-btn"; + editCancelBtn.title = "Cancel edit (Esc)"; + editCancelBtn.setAttribute("aria-label", "Cancel editing"); + editCancelBtn.innerHTML = ''; + + // Confirm button (✓) + editConfirmBtn = document.createElement("button"); + editConfirmBtn.className = "icon-btn edit-confirm-btn"; + editConfirmBtn.title = "Confirm edit (Enter)"; + editConfirmBtn.setAttribute("aria-label", "Confirm edit"); + editConfirmBtn.innerHTML = ''; + + // Assemble edit actions + editActionsContainer.appendChild(editLabel); + let btnGroup = document.createElement("div"); + btnGroup.className = "edit-btn-group"; + btnGroup.appendChild(editCancelBtn); + btnGroup.appendChild(editConfirmBtn); + editActionsContainer.appendChild(btnGroup); + + // Insert into actions bar (will be shown/hidden as needed) + if (actionsBar) { + actionsBar.appendChild(editActionsContainer); + } +} + +function createApprovalModal() { + // Create approval bar that appears at the top of input-wrapper (inside the border) + approvalModal = document.createElement("div"); + approvalModal.className = "approval-bar hidden"; + approvalModal.id = "approval-bar"; + approvalModal.setAttribute("role", "toolbar"); + approvalModal.setAttribute("aria-label", "Quick approval options"); + + // Left side label + let labelSpan = document.createElement("span"); + labelSpan.className = "approval-label"; + labelSpan.textContent = "Waiting on your input.."; + + // Right side buttons container + let buttonsContainer = document.createElement("div"); + buttonsContainer.className = "approval-buttons"; + + // No/Reject button (secondary action - text only) + approvalNoBtn = document.createElement("button"); + approvalNoBtn.className = "approval-btn approval-reject-btn"; + approvalNoBtn.setAttribute( + "aria-label", + "Reject and provide custom response", + ); + approvalNoBtn.textContent = "No"; + + // Continue/Accept button (primary action) + approvalContinueBtn = document.createElement("button"); + approvalContinueBtn.className = "approval-btn approval-accept-btn"; + approvalContinueBtn.setAttribute("aria-label", "Yes and continue"); + approvalContinueBtn.textContent = "Yes"; + + // Assemble buttons + buttonsContainer.appendChild(approvalNoBtn); + buttonsContainer.appendChild(approvalContinueBtn); + + // Assemble bar + approvalModal.appendChild(labelSpan); + approvalModal.appendChild(buttonsContainer); + + // Insert at top of input-wrapper (inside the border) + let inputWrapper = document.getElementById("input-wrapper"); + if (inputWrapper) { + inputWrapper.insertBefore(approvalModal, inputWrapper.firstChild); + } +} + +function createSettingsModal() { + // Create modal overlay + settingsModalOverlay = document.createElement("div"); + settingsModalOverlay.className = "settings-modal-overlay hidden"; + settingsModalOverlay.id = "settings-modal-overlay"; + + // Create modal container + settingsModal = document.createElement("div"); + settingsModal.className = "settings-modal"; + settingsModal.id = "settings-modal"; + settingsModal.setAttribute("role", "dialog"); + settingsModal.setAttribute("aria-labelledby", "settings-modal-title"); + + // Modal header + let modalHeader = document.createElement("div"); + modalHeader.className = "settings-modal-header"; + + let titleSpan = document.createElement("span"); + titleSpan.className = "settings-modal-title"; + titleSpan.id = "settings-modal-title"; + titleSpan.textContent = "Settings"; + modalHeader.appendChild(titleSpan); + + // Header buttons container + let headerButtons = document.createElement("div"); + headerButtons.className = "settings-modal-header-buttons"; + + // Report Issue button + let reportBtn = document.createElement("button"); + reportBtn.className = "settings-modal-header-btn"; + reportBtn.innerHTML = ''; + reportBtn.title = "Report Issue"; + reportBtn.setAttribute("aria-label", "Report an issue on GitHub"); + reportBtn.addEventListener("click", function () { + vscode.postMessage({ + type: "openExternal", + url: "https://github.com/4regab/TaskSync/issues/new", + }); + }); + headerButtons.appendChild(reportBtn); + + // Close button + settingsModalClose = document.createElement("button"); + settingsModalClose.className = "settings-modal-header-btn"; + settingsModalClose.innerHTML = ''; + settingsModalClose.title = "Close"; + settingsModalClose.setAttribute("aria-label", "Close settings"); + headerButtons.appendChild(settingsModalClose); + + modalHeader.appendChild(headerButtons); + + // Modal content + let modalContent = document.createElement("div"); + modalContent.className = "settings-modal-content"; + + // Sound section - simplified, toggle right next to header + let soundSection = document.createElement("div"); + soundSection.className = "settings-section"; + soundSection.innerHTML = + '
    ' + + '
    Notifications
    ' + + '
    ' + + "
    "; + modalContent.appendChild(soundSection); + + // Interactive approval section - toggle interactive Yes/No + choices UI + let approvalSection = document.createElement("div"); + approvalSection.className = "settings-section"; + approvalSection.innerHTML = + '
    ' + + '
    Interactive Approvals
    ' + + '
    ' + + "
    "; + modalContent.appendChild(approvalSection); + + // Send shortcut section - switch between Enter and Ctrl/Cmd+Enter send + let sendShortcutSection = document.createElement("div"); + sendShortcutSection.className = "settings-section"; + sendShortcutSection.innerHTML = + '
    ' + + '
    Ctrl/Cmd+Enter to Send
    ' + + '
    ' + + "
    "; + modalContent.appendChild(sendShortcutSection); + + // Autopilot section with cycling prompts list + let autopilotSection = document.createElement("div"); + autopilotSection.className = "settings-section"; + autopilotSection.innerHTML = + '
    ' + + '
    ' + + ' Autopilot Prompts' + + '' + + '' + + "
    " + + '' + + "
    " + + '
    ' + + '"; + modalContent.appendChild(autopilotSection); + + // Response Timeout section - dropdown for 10-120 minutes + let timeoutSection = document.createElement("div"); + timeoutSection.className = "settings-section"; + // Generate options from SSOT constant + let timeoutOptions = Array.from(RESPONSE_TIMEOUT_ALLOWED_VALUES) + .sort(function (a, b) { + return a - b; + }) + .map(function (val) { + let label = val === 0 ? "Disabled" : val + " minutes"; + if (val === RESPONSE_TIMEOUT_DEFAULT) label += " (default)"; + if (val >= 120 && val % 60 === 0) + label = val + " minutes (" + val / 60 + "h)"; + else if (val >= 90 && val % 30 === 0 && val !== 90) + label = val + " minutes (" + (val / 60).toFixed(1) + "h)"; + return '"; + }) + .join(""); + timeoutSection.innerHTML = + '
    ' + + '
    ' + + ' Response Timeout' + + '' + + '' + + "
    " + + "
    " + + '
    ' + + '" + + "
    "; + modalContent.appendChild(timeoutSection); + + // Session Warning section - warning threshold in hours + let sessionWarningSection = document.createElement("div"); + sessionWarningSection.className = "settings-section"; + sessionWarningSection.innerHTML = + '
    ' + + '
    ' + + ' Session Warning' + + '' + + '' + + "
    " + + "
    " + + '
    ' + + '" + + "
    "; + modalContent.appendChild(sessionWarningSection); + + // Max Consecutive Auto-Responses section - number input + let maxAutoSection = document.createElement("div"); + maxAutoSection.className = "settings-section"; + maxAutoSection.innerHTML = + '
    ' + + '
    ' + + ' Max Auto-Responses' + + '' + + '' + + "
    " + + "
    " + + '
    ' + + '' + + "
    "; + modalContent.appendChild(maxAutoSection); + + // Human-Like Delay section - toggle + min/max inputs + let humanDelaySection = document.createElement("div"); + humanDelaySection.className = "settings-section"; + humanDelaySection.innerHTML = + '
    ' + + '
    ' + + ' Human-Like Delay' + + '' + + '' + + "
    " + + '
    ' + + "
    " + + '
    ' + + '' + + '' + + '' + + '' + + "
    "; + modalContent.appendChild(humanDelaySection); + + // Reusable Prompts section - plus button next to title + let promptsSection = document.createElement("div"); + promptsSection.className = "settings-section"; + promptsSection.innerHTML = + '
    ' + + '
    Reusable Prompts
    ' + + '' + + "
    " + + '
    ' + + ''; + modalContent.appendChild(promptsSection); + + // Assemble modal + settingsModal.appendChild(modalHeader); + settingsModal.appendChild(modalContent); + settingsModalOverlay.appendChild(settingsModal); + + // Add to DOM + document.body.appendChild(settingsModalOverlay); + + // Cache inner elements + soundToggle = document.getElementById("sound-toggle"); + interactiveApprovalToggle = document.getElementById( + "interactive-approval-toggle", + ); + sendShortcutToggle = document.getElementById("send-shortcut-toggle"); + autopilotPromptsList = document.getElementById("autopilot-prompts-list"); + autopilotAddBtn = document.getElementById("autopilot-add-btn"); + addAutopilotPromptForm = document.getElementById("add-autopilot-prompt-form"); + autopilotPromptInput = document.getElementById("autopilot-prompt-input"); + saveAutopilotPromptBtn = document.getElementById("save-autopilot-prompt-btn"); + cancelAutopilotPromptBtn = document.getElementById( + "cancel-autopilot-prompt-btn", + ); + responseTimeoutSelect = document.getElementById("response-timeout-select"); + sessionWarningHoursSelect = document.getElementById( + "session-warning-hours-select", + ); + maxAutoResponsesInput = document.getElementById("max-auto-responses-input"); + humanDelayToggle = document.getElementById("human-delay-toggle"); + humanDelayRangeContainer = document.getElementById("human-delay-range"); + humanDelayMinInput = document.getElementById("human-delay-min-input"); + humanDelayMaxInput = document.getElementById("human-delay-max-input"); + promptsList = document.getElementById("prompts-list"); + addPromptBtn = document.getElementById("add-prompt-btn"); + addPromptForm = document.getElementById("add-prompt-form"); +} + +// ===== NEW SESSION MODAL ===== + +var newSessionModalOverlay = null; + +function createNewSessionModal() { + newSessionModalOverlay = document.createElement("div"); + newSessionModalOverlay.className = "settings-modal-overlay hidden"; + newSessionModalOverlay.id = "new-session-modal-overlay"; + + var modal = document.createElement("div"); + modal.className = "settings-modal new-session-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-labelledby", "new-session-modal-title"); + + // Header + var header = document.createElement("div"); + header.className = "settings-modal-header"; + var title = document.createElement("span"); + title.className = "settings-modal-title"; + title.id = "new-session-modal-title"; + title.textContent = "New Session"; + header.appendChild(title); + var headerBtns = document.createElement("div"); + headerBtns.className = "settings-modal-header-buttons"; + var closeBtn = document.createElement("button"); + closeBtn.className = "settings-modal-header-btn"; + closeBtn.innerHTML = ''; + closeBtn.title = "Cancel"; + closeBtn.setAttribute("aria-label", "Cancel"); + closeBtn.addEventListener("click", closeNewSessionModal); + headerBtns.appendChild(closeBtn); + header.appendChild(headerBtns); + + // Content + var content = document.createElement("div"); + content.className = "settings-modal-content new-session-modal-content"; + + // Model note + var modelNote = document.createElement("p"); + modelNote.className = "new-session-note"; + modelNote.innerHTML = + ' Please check the model preselected in VS Code\'s Agent Mode before starting.'; + content.appendChild(modelNote); + + // Warning message + var warning = document.createElement("p"); + warning.className = "new-session-warning"; + warning.textContent = + "This will clear the current session history and start fresh."; + content.appendChild(warning); + + // Button row + var btnRow = document.createElement("div"); + btnRow.className = "new-session-btn-row"; + var cancelBtn = document.createElement("button"); + cancelBtn.className = "form-btn form-btn-cancel"; + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", closeNewSessionModal); + btnRow.appendChild(cancelBtn); + + var confirmBtn = document.createElement("button"); + confirmBtn.className = "form-btn form-btn-save"; + confirmBtn.textContent = "New Session"; + confirmBtn.addEventListener("click", function () { + closeNewSessionModal(); + vscode.postMessage({ type: "newSession" }); + }); + btnRow.appendChild(confirmBtn); + content.appendChild(btnRow); + + modal.appendChild(header); + modal.appendChild(content); + newSessionModalOverlay.appendChild(modal); + document.body.appendChild(newSessionModalOverlay); + + // Close on overlay click + newSessionModalOverlay.addEventListener("click", function (e) { + if (e.target === newSessionModalOverlay) closeNewSessionModal(); + }); +} + +function openNewSessionModal() { + if (!newSessionModalOverlay) return; + newSessionModalOverlay.classList.remove("hidden"); +} + +function closeNewSessionModal() { + if (!newSessionModalOverlay) return; + newSessionModalOverlay.classList.add("hidden"); +} +// ==================== Event Listeners ==================== + +function bindEventListeners() { + if (chatInput) { + chatInput.addEventListener("input", handleTextareaInput); + chatInput.addEventListener("keydown", handleTextareaKeydown); + chatInput.addEventListener("paste", handlePaste); + // Sync scroll between textarea and highlighter + chatInput.addEventListener("scroll", function () { + if (inputHighlighter) { + inputHighlighter.scrollTop = chatInput.scrollTop; + } + }); + } + if (sendBtn) sendBtn.addEventListener("click", handleSend); + if (attachBtn) attachBtn.addEventListener("click", handleAttach); + if (modeBtn) modeBtn.addEventListener("click", toggleModeDropdown); + + document + .querySelectorAll(".mode-option[data-mode]") + .forEach(function (option) { + option.addEventListener("click", function () { + setMode(option.getAttribute("data-mode"), true); + closeModeDropdown(); + }); + }); + + document.addEventListener("click", function (e) { + let markdownLink = + e.target && e.target.closest + ? e.target.closest("a.markdown-link[data-link-target]") + : null; + if (markdownLink) { + e.preventDefault(); + let markdownLinksApi = window.TaskSyncMarkdownLinks; + let encodedTarget = markdownLink.getAttribute("data-link-target"); + if ( + encodedTarget && + markdownLinksApi && + typeof markdownLinksApi.toWebviewMessage === "function" + ) { + const linkMessage = markdownLinksApi.toWebviewMessage(encodedTarget); + if (linkMessage) { + vscode.postMessage(linkMessage); + } + } + return; + } + + if ( + dropdownOpen && + !e.target.closest(".mode-selector") && + !e.target.closest(".mode-dropdown") + ) + closeModeDropdown(); + if ( + autocompleteVisible && + !e.target.closest(".autocomplete-dropdown") && + !e.target.closest("#chat-input") + ) + hideAutocomplete(); + if ( + slashDropdownVisible && + !e.target.closest(".slash-dropdown") && + !e.target.closest("#chat-input") + ) + hideSlashDropdown(); + }); + + // Remember right-click target so context-menu Copy can resolve the exact clicked message. + document.addEventListener("contextmenu", handleContextMenu); + // Intercept Copy when nothing is selected and copy clicked message text as-is. + document.addEventListener("copy", handleCopy); + + if (queueHeader) + queueHeader.addEventListener("click", handleQueueHeaderClick); + if (historyModalClose) + historyModalClose.addEventListener("click", closeHistoryModal); + if (historyModalClearAll) + historyModalClearAll.addEventListener("click", clearAllPersistedHistory); + if (historyModalOverlay) { + historyModalOverlay.addEventListener("click", function (e) { + if (e.target === historyModalOverlay) closeHistoryModal(); + }); + } + // Edit mode button events + if (editCancelBtn) editCancelBtn.addEventListener("click", cancelEditMode); + if (editConfirmBtn) editConfirmBtn.addEventListener("click", confirmEditMode); + + // Approval modal button events + if (approvalContinueBtn) + approvalContinueBtn.addEventListener("click", handleApprovalContinue); + if (approvalNoBtn) approvalNoBtn.addEventListener("click", handleApprovalNo); + + // Settings modal events + if (settingsModalClose) + settingsModalClose.addEventListener("click", closeSettingsModal); + if (settingsModalOverlay) { + settingsModalOverlay.addEventListener("click", function (e) { + if (e.target === settingsModalOverlay) closeSettingsModal(); + }); + } + if (soundToggle) { + soundToggle.addEventListener("click", toggleSoundSetting); + soundToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSoundSetting(); + } + }); + } + if (interactiveApprovalToggle) { + interactiveApprovalToggle.addEventListener( + "click", + toggleInteractiveApprovalSetting, + ); + interactiveApprovalToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleInteractiveApprovalSetting(); + } + }); + } + if (sendShortcutToggle) { + sendShortcutToggle.addEventListener( + "click", + toggleSendWithCtrlEnterSetting, + ); + sendShortcutToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSendWithCtrlEnterSetting(); + } + }); + } + if (autopilotToggle) { + autopilotToggle.addEventListener("click", toggleAutopilotSetting); + autopilotToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleAutopilotSetting(); + } + }); + } + // Autopilot prompts list event listeners + if (autopilotAddBtn) { + autopilotAddBtn.addEventListener("click", showAddAutopilotPromptForm); + } + if (saveAutopilotPromptBtn) { + saveAutopilotPromptBtn.addEventListener("click", saveAutopilotPrompt); + } + if (cancelAutopilotPromptBtn) { + cancelAutopilotPromptBtn.addEventListener( + "click", + hideAddAutopilotPromptForm, + ); + } + if (autopilotPromptsList) { + autopilotPromptsList.addEventListener( + "click", + handleAutopilotPromptsListClick, + ); + // Drag and drop for reordering + autopilotPromptsList.addEventListener( + "dragstart", + handleAutopilotDragStart, + ); + autopilotPromptsList.addEventListener("dragover", handleAutopilotDragOver); + autopilotPromptsList.addEventListener("dragend", handleAutopilotDragEnd); + autopilotPromptsList.addEventListener("drop", handleAutopilotDrop); + } + if (responseTimeoutSelect) { + responseTimeoutSelect.addEventListener( + "change", + handleResponseTimeoutChange, + ); + } + if (sessionWarningHoursSelect) { + sessionWarningHoursSelect.addEventListener( + "change", + handleSessionWarningHoursChange, + ); + } + if (maxAutoResponsesInput) { + maxAutoResponsesInput.addEventListener( + "change", + handleMaxAutoResponsesChange, + ); + maxAutoResponsesInput.addEventListener( + "blur", + handleMaxAutoResponsesChange, + ); + } + if (humanDelayToggle) { + humanDelayToggle.addEventListener("click", toggleHumanDelaySetting); + humanDelayToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleHumanDelaySetting(); + } + }); + } + if (humanDelayMinInput) { + humanDelayMinInput.addEventListener("change", handleHumanDelayMinChange); + humanDelayMinInput.addEventListener("blur", handleHumanDelayMinChange); + } + if (humanDelayMaxInput) { + humanDelayMaxInput.addEventListener("change", handleHumanDelayMaxChange); + humanDelayMaxInput.addEventListener("blur", handleHumanDelayMaxChange); + } + if (addPromptBtn) addPromptBtn.addEventListener("click", showAddPromptForm); + // Add prompt form events (deferred - bind after modal created) + let cancelPromptBtn = document.getElementById("cancel-prompt-btn"); + let savePromptBtn = document.getElementById("save-prompt-btn"); + if (cancelPromptBtn) + cancelPromptBtn.addEventListener("click", hideAddPromptForm); + if (savePromptBtn) savePromptBtn.addEventListener("click", saveNewPrompt); + + window.addEventListener("message", handleExtensionMessage); +} +// ==================== History Modal ==================== + +function openHistoryModal() { + if (!historyModalOverlay) return; + // Request persisted history from extension + vscode.postMessage({ type: "openHistoryModal" }); + historyModalOverlay.classList.remove("hidden"); +} + +function closeHistoryModal() { + if (!historyModalOverlay) return; + historyModalOverlay.classList.add("hidden"); +} + +function clearAllPersistedHistory() { + if (persistedHistory.length === 0) return; + vscode.postMessage({ type: "clearPersistedHistory" }); + persistedHistory = []; + renderHistoryModal(); +} + +function initCardSelection() { + if (cardVibe) { + cardVibe.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + selectCard("normal", true); + }); + } + if (cardSpec) { + cardSpec.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + selectCard("queue", true); + }); + } + // Don't set default here - wait for updateQueue message from extension + // which contains the persisted enabled state + updateCardSelection(); +} + +function selectCard(card, notify) { + selectedCard = card; + queueEnabled = card === "queue"; + updateCardSelection(); + updateModeUI(); + updateQueueVisibility(); + + // Only notify extension if user clicked (not on init from persisted state) + if (notify) { + vscode.postMessage({ type: "toggleQueue", enabled: queueEnabled }); + } +} + +function updateCardSelection() { + // card-vibe = Normal mode, card-spec = Queue mode + if (cardVibe) cardVibe.classList.toggle("selected", !queueEnabled); + if (cardSpec) cardSpec.classList.toggle("selected", queueEnabled); +} +// ==================== Input Handling ==================== + +function autoResizeTextarea() { + if (!chatInput) return; + chatInput.style.height = "auto"; + chatInput.style.height = Math.min(chatInput.scrollHeight, 150) + "px"; +} + +/** + * Update the input highlighter overlay to show syntax highlighting + * for slash commands (/command) and file references (#file) + */ +function updateInputHighlighter() { + if (!inputHighlighter || !chatInput) return; + + let text = chatInput.value; + if (!text) { + inputHighlighter.innerHTML = ""; + return; + } + + // Build a list of known slash command names for exact matching + let knownSlashNames = reusablePrompts.map(function (p) { + return p.name; + }); + // Also add any pending stored mappings + let mappings = chatInput._slashPrompts || {}; + Object.keys(mappings).forEach(function (name) { + if (knownSlashNames.indexOf(name) === -1) knownSlashNames.push(name); + }); + + // Escape HTML first + let html = escapeHtml(text); + + // Highlight slash commands - match /word patterns + // Only highlight if it's a known command OR any /word pattern + html = html.replace( + /(^|\s)(\/[a-zA-Z0-9_-]+)(\s|$)/g, + function (match, before, slash, after) { + let cmdName = slash.substring(1); // Remove the / + // Highlight if it's a known command or if we have prompts defined + if ( + knownSlashNames.length === 0 || + knownSlashNames.indexOf(cmdName) >= 0 + ) { + return ( + before + '' + slash + "" + after + ); + } + // Still highlight as generic slash command + return ( + before + '' + slash + "" + after + ); + }, + ); + + // Highlight file references - match #word patterns + html = html.replace( + /(^|\s)(#[a-zA-Z0-9_.\/-]+)(\s|$)/g, + function (match, before, hash, after) { + return ( + before + '' + hash + "" + after + ); + }, + ); + + // Don't add trailing space - causes visual artifacts + // html += ' '; + + inputHighlighter.innerHTML = html; + + // Sync scroll position + inputHighlighter.scrollTop = chatInput.scrollTop; +} + +function handleTextareaInput() { + autoResizeTextarea(); + updateInputHighlighter(); + handleAutocomplete(); + handleSlashCommands(); + // Context items (#terminal, #problems) now handled via handleAutocomplete() + syncAttachmentsWithText(); + updateSendButtonState(); + // Persist input value so it survives sidebar tab switches + saveWebviewState(); +} + +function updateSendButtonState() { + if (!sendBtn || !chatInput) return; + let hasText = chatInput.value.trim().length > 0; + sendBtn.classList.toggle("has-text", hasText); +} + +function handleTextareaKeydown(e) { + // Handle approval modal keyboard shortcuts when visible + if ( + isApprovalQuestion && + approvalModal && + !approvalModal.classList.contains("hidden") + ) { + // Enter sends "Continue" when approval modal is visible and input is empty + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + let inputText = chatInput ? chatInput.value.trim() : ""; + if (!inputText) { + e.preventDefault(); + handleApprovalContinue(); + return; + } + // If there's text, fall through to normal send behavior + } + // Escape dismisses approval modal + if (e.key === "Escape") { + e.preventDefault(); + handleApprovalNo(); + return; + } + } + + // Handle edit mode keyboard shortcuts + if (editingPromptId) { + if (e.key === "Escape") { + e.preventDefault(); + cancelEditMode(); + return; + } + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + confirmEditMode(); + return; + } + // Allow other keys in edit mode + return; + } + + // Handle slash command dropdown navigation + if (slashDropdownVisible) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (selectedSlashIndex < slashResults.length - 1) { + selectedSlashIndex++; + updateSlashSelection(); + } + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (selectedSlashIndex > 0) { + selectedSlashIndex--; + updateSlashSelection(); + } + return; + } + if ((e.key === "Enter" || e.key === "Tab") && selectedSlashIndex >= 0) { + e.preventDefault(); + selectSlashItem(selectedSlashIndex); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + hideSlashDropdown(); + return; + } + } + + if (autocompleteVisible) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (selectedAutocompleteIndex < autocompleteResults.length - 1) { + selectedAutocompleteIndex++; + updateAutocompleteSelection(); + } + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (selectedAutocompleteIndex > 0) { + selectedAutocompleteIndex--; + updateAutocompleteSelection(); + } + return; + } + if ( + (e.key === "Enter" || e.key === "Tab") && + selectedAutocompleteIndex >= 0 + ) { + e.preventDefault(); + selectAutocompleteItem(selectedAutocompleteIndex); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + hideAutocomplete(); + return; + } + } + + // Context dropdown navigation removed - context now uses # via file autocomplete + + let isPlainEnter = + e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey; + let isCtrlOrCmdEnter = + e.key === "Enter" && !e.shiftKey && (e.ctrlKey || e.metaKey); + + if (!sendWithCtrlEnter && isPlainEnter) { + e.preventDefault(); + handleSend(); + return; + } + + if (sendWithCtrlEnter && isCtrlOrCmdEnter) { + e.preventDefault(); + handleSend(); + return; + } +} + +/** + * Handle send action triggered by VS Code command/keybinding. + * Mirrors Enter behavior while avoiding sends when input is not focused. + */ +function handleSendFromShortcut() { + if (!chatInput || document.activeElement !== chatInput) { + return; + } + + if ( + isApprovalQuestion && + approvalModal && + !approvalModal.classList.contains("hidden") + ) { + let inputText = chatInput.value.trim(); + if (!inputText) { + handleApprovalContinue(); + return; + } + } + + if (editingPromptId) { + confirmEditMode(); + return; + } + + if (slashDropdownVisible && selectedSlashIndex >= 0) { + selectSlashItem(selectedSlashIndex); + return; + } + + if (autocompleteVisible && selectedAutocompleteIndex >= 0) { + selectAutocompleteItem(selectedAutocompleteIndex); + return; + } + + handleSend(); +} + +function handleSend() { + let text = chatInput ? chatInput.value.trim() : ""; + if (!text && currentAttachments.length === 0) { + // If choices are selected and input is empty, send the selected choices + let choicesBar = document.getElementById("choices-bar"); + if (choicesBar && !choicesBar.classList.contains("hidden")) { + let selectedButtons = choicesBar.querySelectorAll(".choice-btn.selected"); + if (selectedButtons.length > 0) { + handleChoicesSend(); + return; + } + } + return; + } + + // Expand slash commands to full prompt text + text = expandSlashCommands(text); + + // Hide approval modal when sending any response + hideApprovalModal(); + + // If processing response (AI working), auto-queue the message + if (isProcessingResponse && text) { + addToQueue(text); + // This reduces friction - user's prompt is in queue, so show them queue mode + if (!queueEnabled) { + queueEnabled = true; + updateModeUI(); + updateQueueVisibility(); + updateCardSelection(); + vscode.postMessage({ type: "toggleQueue", enabled: true }); + } + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + // Clear persisted state after sending + saveWebviewState(); + return; + } + + // In remote mode with no pending tool call and no active session, + // bypass queue mode and use direct chat for immediate response. + var bypassQueueForChat = + isRemoteMode && + !pendingToolCall && + text && + currentSessionCalls.length === 0; + debugLog( + "handleSend: isRemote:", + isRemoteMode, + "bypass:", + bypassQueueForChat, + "queue:", + queueEnabled, + "sessions:", + currentSessionCalls.length, + ); + + if (!bypassQueueForChat && queueEnabled && text && !pendingToolCall) { + debugLog("handleSend: → addToQueue"); + addToQueue(text); + } else if (isRemoteMode && !pendingToolCall && text) { + debugLog("handleSend: → chatMessage"); + addChatStreamUserBubble(text); + vscode.postMessage({ type: "chatMessage", content: text }); + } else { + vscode.postMessage({ + type: "submit", + value: text, + attachments: currentAttachments, + }); + } + + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + // Clear persisted state after sending + saveWebviewState(); +} + +function handleAttach() { + vscode.postMessage({ type: "addAttachment" }); +} + +function toggleModeDropdown(e) { + e.stopPropagation(); + if (dropdownOpen) closeModeDropdown(); + else { + dropdownOpen = true; + positionModeDropdown(); + modeDropdown.classList.remove("hidden"); + modeDropdown.classList.add("visible"); + } +} + +function positionModeDropdown() { + if (!modeDropdown || !modeBtn) return; + let rect = modeBtn.getBoundingClientRect(); + modeDropdown.style.bottom = window.innerHeight - rect.top + 4 + "px"; + modeDropdown.style.left = rect.left + "px"; +} + +function closeModeDropdown() { + dropdownOpen = false; + if (modeDropdown) { + modeDropdown.classList.remove("visible"); + modeDropdown.classList.add("hidden"); + } +} + +function setMode(mode, notify) { + queueEnabled = mode === "queue"; + updateModeUI(); + updateQueueVisibility(); + updateCardSelection(); + if (notify) + vscode.postMessage({ type: "toggleQueue", enabled: queueEnabled }); +} + +function updateModeUI() { + if (modeLabel) modeLabel.textContent = queueEnabled ? "Queue" : "Normal"; + document.querySelectorAll(".mode-option[data-mode]").forEach(function (opt) { + opt.classList.toggle( + "selected", + opt.getAttribute("data-mode") === (queueEnabled ? "queue" : "normal"), + ); + }); +} + +function updateQueueVisibility() { + if (!queueSection) return; + // Hide queue section if: not in queue mode OR queue is empty + let shouldHide = !queueEnabled || promptQueue.length === 0; + let wasHidden = queueSection.classList.contains("hidden"); + queueSection.classList.toggle("hidden", shouldHide); + // Only collapse when showing for the FIRST time (was hidden, now visible) + // Don't collapse on subsequent updates to preserve user's expanded state + if (wasHidden && !shouldHide && promptQueue.length > 0) { + queueSection.classList.add("collapsed"); + } +} + +function handleQueueHeaderClick() { + if (queueSection) queueSection.classList.toggle("collapsed"); +} + +function normalizeResponseTimeout(value) { + if (!Number.isFinite(value)) { + return RESPONSE_TIMEOUT_DEFAULT; + } + if (!RESPONSE_TIMEOUT_ALLOWED_VALUES.has(value)) { + return RESPONSE_TIMEOUT_DEFAULT; + } + return value; +} +// ==================== Extension Message Handler ==================== + +function handleExtensionMessage(event) { + let message = event.data; + switch (message.type) { + case "updateQueue": + promptQueue = message.queue || []; + queueEnabled = message.enabled !== false; + renderQueue(); + updateModeUI(); + updateQueueVisibility(); + updateCardSelection(); + // Hide welcome section if we have current session calls + updateWelcomeSectionVisibility(); + break; + case "toolCallPending": + showPendingToolCall( + message.id, + message.prompt, + message.isApproval, + message.choices, + message.summary, + ); + break; + case "toolCallCompleted": + addToolCallToCurrentSession(message.entry, message.sessionTerminated); + break; + case "updateCurrentSession": + currentSessionCalls = message.history || []; + renderCurrentSession(); + // Hide welcome section if we have completed tool calls + updateWelcomeSectionVisibility(); + // Auto-scroll to bottom after rendering + scrollToBottom(); + break; + case "updatePersistedHistory": + persistedHistory = message.history || []; + renderHistoryModal(); + break; + case "openHistoryModal": + openHistoryModal(); + break; + case "openSettingsModal": + openSettingsModal(); + break; + case "updateSettings": + soundEnabled = message.soundEnabled !== false; + interactiveApprovalEnabled = message.interactiveApprovalEnabled !== false; + sendWithCtrlEnter = message.sendWithCtrlEnter === true; + autopilotEnabled = message.autopilotEnabled === true; + autopilotText = + typeof message.autopilotText === "string" ? message.autopilotText : ""; + autopilotPrompts = Array.isArray(message.autopilotPrompts) + ? message.autopilotPrompts + : []; + reusablePrompts = message.reusablePrompts || []; + responseTimeout = normalizeResponseTimeout(message.responseTimeout); + sessionWarningHours = + typeof message.sessionWarningHours === "number" + ? message.sessionWarningHours + : DEFAULT_SESSION_WARNING_HOURS; + maxConsecutiveAutoResponses = + typeof message.maxConsecutiveAutoResponses === "number" + ? message.maxConsecutiveAutoResponses + : DEFAULT_MAX_AUTO_RESPONSES; + humanLikeDelayEnabled = message.humanLikeDelayEnabled !== false; + humanLikeDelayMin = + typeof message.humanLikeDelayMin === "number" + ? message.humanLikeDelayMin + : DEFAULT_HUMAN_DELAY_MIN; + humanLikeDelayMax = + typeof message.humanLikeDelayMax === "number" + ? message.humanLikeDelayMax + : DEFAULT_HUMAN_DELAY_MAX; + updateSoundToggleUI(); + updateInteractiveApprovalToggleUI(); + updateSendWithCtrlEnterToggleUI(); + updateAutopilotToggleUI(); + renderAutopilotPromptsList(); + updateResponseTimeoutUI(); + updateSessionWarningHoursUI(); + updateMaxAutoResponsesUI(); + updateHumanDelayUI(); + renderPromptsList(); + break; + case "slashCommandResults": + showSlashDropdown(message.prompts || []); + break; + case "playNotificationSound": + playNotificationSound(); + break; + case "fileSearchResults": + showAutocomplete(message.files || []); + break; + case "updateAttachments": + currentAttachments = message.attachments || []; + updateChipsDisplay(); + break; + case "imageSaved": + if ( + message.attachment && + !currentAttachments.some(function (a) { + return a.id === message.attachment.id; + }) + ) { + currentAttachments.push(message.attachment); + updateChipsDisplay(); + } + break; + case "clear": + console.log("[TaskSync Webview] clear — resetting session state"); + promptQueue = []; + currentSessionCalls = []; + pendingToolCall = null; + isProcessingResponse = false; + renderQueue(); + renderCurrentSession(); + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    ' + + ' New session started — waiting for AI' + + "
    "; + } + updateWelcomeSectionVisibility(); + break; + case "updateSessionTimer": + // Timer is displayed in the view title bar by the extension host + // No webview UI to update + break; + case "triggerSendFromShortcut": + handleSendFromShortcut(); + break; + } +} + +function showPendingToolCall(id, prompt, isApproval, choices, summary) { + console.log( + "[TaskSync Webview] showPendingToolCall — id:", + id, + "hasSummary:", + !!summary, + "summaryLength:", + summary ? summary.length : 0, + "promptLength:", + prompt ? prompt.length : 0, + ); + pendingToolCall = { id: id, prompt: prompt, summary: summary || "" }; + isProcessingResponse = false; // AI is now asking, not processing + isApprovalQuestion = isApproval === true; + currentChoices = choices || []; + + if (welcomeSection) { + welcomeSection.classList.add("hidden"); + } + + // Add pending class to disable session switching UI + document.body.classList.add("has-pending-toolcall"); + + // Show AI question (and summary if present) as rendered markdown + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + let pendingHtml = ""; + if (summary) { + console.log( + "[TaskSync Webview] Rendering summary in pending view — length:", + summary.length, + "preview:", + summary.slice(0, 80), + ); + pendingHtml += + '
    ' + formatMarkdown(summary) + "
    "; + } else { + console.log("[TaskSync Webview] No summary to render in pending view"); + } + pendingHtml += + '
    ' + formatMarkdown(prompt) + "
    "; + console.log( + "[TaskSync Webview] Pending HTML set — totalLength:", + pendingHtml.length, + ); + pendingMessage.innerHTML = pendingHtml; + } else { + console.error("[TaskSync Webview] pendingMessage element is null!"); + } + + // Re-render current session (without the pending item - it's shown separately) + renderCurrentSession(); + // Render any mermaid diagrams in pending message + renderMermaidDiagrams(); + // Auto-scroll to show the new pending message + scrollToBottom(); + + // Show choice buttons if we have choices, otherwise show approval modal for yes/no questions + // Only show if interactive approval is enabled + if (interactiveApprovalEnabled) { + if (currentChoices.length > 0) { + showChoicesBar(); + } else if (isApprovalQuestion) { + showApprovalModal(); + } else { + hideApprovalModal(); + hideChoicesBar(); + } + } else { + // Interactive approval disabled - just focus input for manual typing + hideApprovalModal(); + hideChoicesBar(); + if (chatInput) { + chatInput.focus(); + } + } +} +// ==================== Markdown Utilities ==================== +// Extracted from rendering.js: table processing and list conversion + +// Constants for security and performance limits +let MARKDOWN_MAX_LENGTH = 100000; // Max markdown input length to prevent ReDoS +let MAX_TABLE_ROWS = 100; // Max table rows to process + +/** + * Process a buffer of table lines into HTML table markup (ReDoS-safe). + * Security: Caller (formatMarkdown) must pre-escape HTML before passing lines here. + */ +function processTableBuffer(lines, maxRows) { + if (lines.length < 2) return lines.join("\n"); + if (lines.length > maxRows) return lines.join("\n"); // Skip very large tables + + // Check if second line is separator (contains only |, -, :, spaces) + let separatorRegex = /^\|[\s\-:|]+\|$/; + if (!separatorRegex.test(lines[1].trim())) return lines.join("\n"); + + let headerCells = lines[0].split("|").filter(function (c) { + return c.trim() !== ""; + }); + if (headerCells.length === 0) return lines.join("\n"); + + let headerHtml = + "" + + headerCells + .map(function (c) { + return "" + c.trim() + ""; + }) + .join("") + + ""; + + let bodyHtml = ""; + for (var i = 2; i < lines.length; i++) { + if (!lines[i].trim()) continue; + let cells = lines[i].split("|").filter(function (c) { + return c.trim() !== ""; + }); + bodyHtml += + "" + + cells + .map(function (c) { + return "" + c.trim() + ""; + }) + .join("") + + ""; + } + + return ( + '' + + headerHtml + + "" + + bodyHtml + + "
    " + ); +} + +/** + * Converts markdown lists (ordered/unordered) with indentation-based nesting into HTML. + * Uses 2-space indentation as one nesting level. + * @param {string} text - Escaped markdown text (must already be HTML-escaped by caller) + * @returns {string} Text with markdown lists converted to nested HTML lists + */ +function convertMarkdownLists(text) { + let listLineRegex = /^\s*(?:[-*]|\d+\.)\s.*$/; + let lines = text.split("\n"); + let output = []; + let listBuffer = []; + + function renderListNode(node) { + let startAttr = + node.type === "ol" && typeof node.start === "number" && node.start > 1 + ? ' start="' + node.start + '"' + : ""; + return ( + "<" + + node.type + + startAttr + + ">" + + node.items + .map(function (item) { + let childrenHtml = item.children.map(renderListNode).join(""); + return "
  • " + item.text + childrenHtml + "
  • "; + }) + .join("") + + "" + ); + } + + function processListBuffer(buffer) { + let listItemRegex = /^(\s*)([-*]|\d+\.)\s+(.*)$/; + let rootLists = []; + let stack = []; + + buffer.forEach(function (line) { + let match = listItemRegex.exec(line); + if (!match) return; + + let indent = match[1].replace(/\t/g, " ").length; + let depth = Math.floor(indent / 2); + let marker = match[2]; + let type = marker === "-" || marker === "*" ? "ul" : "ol"; + let text = match[3]; + + while (stack.length > depth + 1) { + stack.pop(); + } + + let entry = stack[depth]; + if (!entry || entry.type !== type) { + const listNode = { + type: type, + items: [], + start: type === "ol" ? parseInt(marker, 10) : null, + }; + + if (depth === 0) { + rootLists.push(listNode); + } else { + const parentEntry = stack[depth - 1]; + if (parentEntry && parentEntry.lastItem) { + parentEntry.lastItem.children.push(listNode); + } else { + rootLists.push(listNode); + } + } + + entry = { type: type, list: listNode, lastItem: null }; + } + + stack = stack.slice(0, depth); + stack[depth] = entry; + + let item = { text: text, children: [] }; + entry.list.items.push(item); + entry.lastItem = item; + stack[depth] = entry; + }); + + return rootLists.map(renderListNode).join(""); + } + + lines.forEach(function (line) { + if (listLineRegex.test(line)) { + listBuffer.push(line); + return; + } + if (listBuffer.length > 0) { + output.push(processListBuffer(listBuffer)); + listBuffer = []; + } + output.push(line); + }); + + if (listBuffer.length > 0) { + output.push(processListBuffer(listBuffer)); + } + + return output.join("\n"); +} +// ==================== Tool Call Rendering ==================== + +function addToolCallToCurrentSession(entry, sessionTerminated) { + pendingToolCall = null; + document.body.classList.remove("has-pending-toolcall"); + hideApprovalModal(); + hideChoicesBar(); + + let idx = currentSessionCalls.findIndex(function (tc) { + return tc.id === entry.id; + }); + if (idx >= 0) { + currentSessionCalls[idx] = entry; + } else { + currentSessionCalls.unshift(entry); + } + renderCurrentSession(); + isProcessingResponse = true; + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + // Check if session terminated + if (sessionTerminated) { + isProcessingResponse = false; + pendingMessage.innerHTML = + '
    ' + + "Session terminated" + + '
    "; + let newSessionBtn = document.getElementById("new-session-btn"); + if (newSessionBtn) { + newSessionBtn.addEventListener("click", function () { + vscode.postMessage({ type: "newSession" }); + }); + } + } else { + pendingMessage.innerHTML = + '
    Processing your response
    '; + } + } + + // Auto-scroll to show the working indicator + scrollToBottom(); +} + +function renderCurrentSession() { + if (!toolHistoryArea) return; + + let completedCalls = currentSessionCalls.filter(function (tc) { + return tc.status === "completed"; + }); + + if (completedCalls.length === 0) { + toolHistoryArea.innerHTML = ""; + return; + } + + // Reverse to show oldest first (new items stack at bottom) + let sortedCalls = completedCalls.slice().reverse(); + + let cardsHtml = sortedCalls + .map(function (tc, index) { + let truncatedTitle; + if (tc.summary) { + truncatedTitle = + tc.summary.length > 120 + ? tc.summary.substring(0, 120) + "..." + : tc.summary; + } else { + let firstSentence = tc.prompt.split(/[.!?]/)[0]; + truncatedTitle = + firstSentence.length > 120 + ? firstSentence.substring(0, 120) + "..." + : firstSentence; + } + let queueBadge = tc.isFromQueue + ? 'Queue' + : ""; + let isLatest = index === sortedCalls.length - 1; + let cardHtml = + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + escapeHtml(truncatedTitle) + + queueBadge + + "" + + "
    " + + "
    " + + '
    ' + + '
    ' + + formatMarkdown(tc.prompt) + + "
    " + + '
    ' + + '
    ' + + escapeHtml(tc.response) + + "
    " + + (tc.attachments ? renderAttachmentsHtml(tc.attachments) : "") + + "
    " + + "
    "; + return cardHtml; + }) + .join(""); + + toolHistoryArea.innerHTML = cardsHtml; + toolHistoryArea + .querySelectorAll(".tool-call-header") + .forEach(function (header) { + header.addEventListener("click", function (e) { + let card = header.closest(".tool-call-card"); + if (card) card.classList.toggle("expanded"); + }); + }); + renderMermaidDiagrams(); +} + +// ——— User message bubble for remote chat ——— +/** Add user message bubble to the chat stream area. */ +function addChatStreamUserBubble(text) { + if (!chatStreamArea) return; + chatStreamArea.classList.remove("hidden"); + var div = document.createElement("div"); + div.className = "chat-stream-msg user"; + div.textContent = text; + chatStreamArea.appendChild(div); + scrollToBottom(); +} + +function renderHistoryModal() { + if (!historyModalList) return; + if (persistedHistory.length === 0) { + historyModalList.innerHTML = + '
    No history yet
    '; + if (historyModalClearAll) historyModalClearAll.classList.add("hidden"); + return; + } + + if (historyModalClearAll) historyModalClearAll.classList.remove("hidden"); + function renderToolCallCard(tc) { + let truncatedTitle; + if (tc.summary) { + truncatedTitle = + tc.summary.length > 80 + ? tc.summary.substring(0, 80) + "..." + : tc.summary; + } else { + let firstSentence = tc.prompt.split(/[.!?]/)[0]; + truncatedTitle = + firstSentence.length > 80 + ? firstSentence.substring(0, 80) + "..." + : firstSentence; + } + let queueBadge = tc.isFromQueue + ? 'Queue' + : ""; + + return ( + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + escapeHtml(truncatedTitle) + + queueBadge + + "" + + "
    " + + '' + + "
    " + + '
    ' + + '
    ' + + formatMarkdown(tc.prompt) + + "
    " + + '
    ' + + '
    ' + + escapeHtml(tc.response) + + "
    " + + (tc.attachments ? renderAttachmentsHtml(tc.attachments) : "") + + "
    " + + "
    " + ); + } + + let cardsHtml = '
    '; + cardsHtml += persistedHistory.map(renderToolCallCard).join(""); + cardsHtml += "
    "; + + historyModalList.innerHTML = cardsHtml; + historyModalList + .querySelectorAll(".tool-call-header") + .forEach(function (header) { + header.addEventListener("click", function (e) { + if (e.target.closest(".tool-call-remove")) return; + let card = header.closest(".tool-call-card"); + if (card) card.classList.toggle("expanded"); + }); + }); + historyModalList + .querySelectorAll(".tool-call-remove") + .forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + let id = btn.getAttribute("data-id"); + if (id) { + vscode.postMessage({ type: "removeHistoryItem", callId: id }); + persistedHistory = persistedHistory.filter(function (tc) { + return tc.id !== id; + }); + renderHistoryModal(); + } + }); + }); +} + +function formatMarkdown(text) { + if (!text) return ""; + + // ReDoS prevention: truncate very long inputs before regex (OWASP mitigation) + if (text.length > MARKDOWN_MAX_LENGTH) { + text = + text.substring(0, MARKDOWN_MAX_LENGTH) + + "\n... (content truncated for display)"; + } + + // Normalize line endings (Windows \r\n to \n) + let processedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Store code blocks BEFORE escaping HTML to preserve backticks + let codeBlocks = []; + let mermaidBlocks = []; + let inlineCodeSpans = []; + + // Extract mermaid blocks first (before HTML escaping) + // Match ```mermaid followed by newline or just content + processedText = processedText.replace( + /```mermaid\s*\n([\s\S]*?)```/g, + function (match, code) { + let index = mermaidBlocks.length; + mermaidBlocks.push(code.trim()); + return "%%MERMAID" + index + "%%"; + }, + ); + + // Extract other code blocks (before HTML escaping) + // Match ```lang or just ``` followed by optional newline + processedText = processedText.replace( + /```(\w*)\s*\n?([\s\S]*?)```/g, + function (match, lang, code) { + let index = codeBlocks.length; + codeBlocks.push({ lang: lang || "", code: code.trim() }); + return "%%CODEBLOCK" + index + "%%"; + }, + ); + + // Extract inline code before escaping (prevents * and _ in `code` from being parsed) + processedText = processedText.replace(/`([^`\n]+)`/g, function (match, code) { + let index = inlineCodeSpans.length; + inlineCodeSpans.push(code); + return "%%INLINECODE" + index + "%%"; + }); + + // Now escape HTML on the remaining text + let html = escapeHtml(processedText); + + // Headers (## Header) - must be at start of line + html = html.replace(/^######\s+(.+)$/gm, "
    $1
    "); + html = html.replace(/^#####\s+(.+)$/gm, "
    $1
    "); + html = html.replace(/^####\s+(.+)$/gm, "

    $1

    "); + html = html.replace(/^###\s+(.+)$/gm, "

    $1

    "); + html = html.replace(/^##\s+(.+)$/gm, "

    $1

    "); + html = html.replace(/^#\s+(.+)$/gm, "

    $1

    "); + + // Horizontal rules (--- or ***) + html = html.replace(/^---+$/gm, "
    "); + html = html.replace(/^\*\*\*+$/gm, "
    "); + + // Blockquotes (> text) - simple single-line support + html = html.replace(/^>\s*(.*)$/gm, "
    $1
    "); + // Merge consecutive blockquotes + html = html.replace(/<\/blockquote>\n
    /g, "\n"); + + // Lists (ordered/unordered, including nested indentation) + // Security contract: html is already escaped above; list conversion must keep item text as-is. + html = convertMarkdownLists(html); + + // Markdown tables - SAFE approach to prevent ReDoS + // Instead of using nested quantifiers with regex (which can cause exponential backtracking), + // we use a line-by-line processing approach for safety + let tableLines = html.split("\n"); + let processedLines = []; + let tableBuffer = []; + let inTable = false; + + for (var lineIdx = 0; lineIdx < tableLines.length; lineIdx++) { + let line = tableLines[lineIdx]; + // Check if line looks like a table row (starts and ends with |) + let isTableRow = /^\|.+\|$/.test(line.trim()); + + if (isTableRow) { + tableBuffer.push(line); + inTable = true; + } else { + if (inTable && tableBuffer.length >= 2) { + // Process accumulated table buffer + const tableHtml = processTableBuffer(tableBuffer, MAX_TABLE_ROWS); + processedLines.push(tableHtml); + } + tableBuffer = []; + inTable = false; + processedLines.push(line); + } + } + // Handle table at end of content + if (inTable && tableBuffer.length >= 2) { + processedLines.push(processTableBuffer(tableBuffer, MAX_TABLE_ROWS)); + } + html = processedLines.join("\n"); + + // Tokenize markdown links before emphasis parsing so link targets are not mutated by markdown transforms. + let markdownLinksApi = window.TaskSyncMarkdownLinks; + let tokenizedLinks = null; + if ( + markdownLinksApi && + typeof markdownLinksApi.tokenizeMarkdownLinks === "function" + ) { + tokenizedLinks = markdownLinksApi.tokenizeMarkdownLinks(html); + html = tokenizedLinks.text; + } + + // Bold (**text** or __text__) + html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); + html = html.replace(/__([^_]+)__/g, "$1"); + + // Strikethrough (~~text~~) + html = html.replace(/~~([^~]+)~~/g, "$1"); + + // Italic (*text* or _text_) + // For *text*: require non-word boundaries around delimiters and alnum at content edges. + // This avoids false-positive matches in plain prose (e.g. regex snippets, list-marker-like asterisks). + html = html.replace( + /(^|[^\p{L}\p{N}_*])\*([\p{L}\p{N}](?:[^*\n]*?[\p{L}\p{N}])?)\*(?=[^\p{L}\p{N}_*]|$)/gu, + "$1$2", + ); + // For _text_: require non-word boundaries (Unicode-aware) around underscore markers + // This keeps punctuation-adjacent emphasis working while avoiding snake_case matches + html = html.replace( + /(^|[^\p{L}\p{N}_])_([^_\s](?:[^_]*[^_\s])?)_(?=[^\p{L}\p{N}_]|$)/gu, + "$1$2", + ); + + // Restore tokenized markdown links after emphasis parsing. + if ( + tokenizedLinks && + markdownLinksApi && + typeof markdownLinksApi.restoreTokenizedLinks === "function" + ) { + html = markdownLinksApi.restoreTokenizedLinks(html, tokenizedLinks.links); + } else if ( + markdownLinksApi && + typeof markdownLinksApi.convertMarkdownLinks === "function" + ) { + html = markdownLinksApi.convertMarkdownLinks(html); + } + + // Restore inline code after emphasis parsing so markdown markers inside code stay literal. + inlineCodeSpans.forEach(function (code, index) { + let escapedCode = escapeHtml(code); + let replacement = '' + escapedCode + ""; + html = html.replace("%%INLINECODE" + index + "%%", replacement); + }); + + // Line breaks - but collapse multiple consecutive breaks + // Don't add
    after block elements + html = html.replace(/\n{3,}/g, "\n\n"); + html = html.replace( + /(<\/h[1-6]>|<\/ul>|<\/ol>|<\/blockquote>|
    )\n/g, + "$1", + ); + html = html.replace(/\n/g, "
    "); + + // Restore code blocks + codeBlocks.forEach(function (block, index) { + let langAttr = block.lang ? ' data-lang="' + block.lang + '"' : ""; + let escapedCode = escapeHtml(block.code); + let replacement = + '
    " +
    +			escapedCode +
    +			"
    "; + html = html.replace("%%CODEBLOCK" + index + "%%", replacement); + }); + + // Restore mermaid blocks as diagrams + mermaidBlocks.forEach(function (code, index) { + let mermaidId = + "mermaid-" + + Date.now() + + "-" + + index + + "-" + + Math.random().toString(36).substr(2, 9); + let replacement = + '
    ' + + escapeHtml(code) + + "
    "; + html = html.replace("%%MERMAID" + index + "%%", replacement); + }); + + // Clean up excessive
    around block elements + html = html.replace( + /(
    )+(|<\/div>|<\/h[1-6]>|<\/ul>|<\/ol>|<\/blockquote>|
    )(
    )+/g, + "$1", + ); + + return html; +} + +// Mermaid rendering - lazy load and render +let mermaidLoaded = false; +let mermaidLoading = false; + +function loadMermaid(callback) { + if (mermaidLoaded) { + callback(); + return; + } + if (mermaidLoading) { + // Wait for existing load (with 10s timeout) + let checkCount = 0; + let checkInterval = setInterval(function () { + checkCount++; + if (mermaidLoaded) { + clearInterval(checkInterval); + callback(); + } else if (checkCount > 200) { + // 10s = 200 * 50ms + clearInterval(checkInterval); + console.error("Mermaid load timeout"); + } + }, 50); + return; + } + mermaidLoading = true; + + let script = document.createElement("script"); + script.src = + "https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js"; + script.crossOrigin = "anonymous"; + script.integrity = + "sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT"; + script.onload = function () { + window.mermaid.initialize({ + startOnLoad: false, + theme: document.body.classList.contains("vscode-light") + ? "default" + : "dark", + securityLevel: "strict", + fontFamily: "var(--vscode-font-family)", + }); + mermaidLoaded = true; + mermaidLoading = false; + callback(); + }; + script.onerror = function () { + mermaidLoading = false; + console.error("Failed to load mermaid.js"); + }; + document.head.appendChild(script); +} + +function renderMermaidDiagrams() { + let containers = document.querySelectorAll( + ".mermaid-container:not(.rendered)", + ); + if (containers.length === 0) return; + + loadMermaid(function () { + containers.forEach(function (container) { + let mermaidDiv = container.querySelector(".mermaid"); + if (!mermaidDiv) return; + + let code = mermaidDiv.textContent; + let id = mermaidDiv.id; + + try { + window.mermaid + .render(id + "-svg", code) + .then(function (result) { + mermaidDiv.innerHTML = result.svg; + container.classList.add("rendered"); + }) + .catch(function (err) { + // Show code block as fallback on error + mermaidDiv.innerHTML = + '
    ' +
    +							escapeHtml(code) +
    +							"
    "; + container.classList.add("rendered", "error"); + }); + } catch (err) { + mermaidDiv.innerHTML = + '
    ' +
    +					escapeHtml(code) +
    +					"
    "; + container.classList.add("rendered", "error"); + } + }); + }); +} + +/** + * Update welcome section visibility based on current session state + * Hide welcome when there are completed tool calls or a pending call + */ +function updateWelcomeSectionVisibility() { + if (!welcomeSection) return; + let hasCompletedCalls = currentSessionCalls.some(function (tc) { + return tc.status === "completed"; + }); + let hasPendingMessage = + pendingMessage && !pendingMessage.classList.contains("hidden"); + let shouldHide = + hasCompletedCalls || pendingToolCall !== null || hasPendingMessage; + welcomeSection.classList.toggle("hidden", shouldHide); +} + +/** + * Auto-scroll chat container to bottom + */ +function scrollToBottom() { + if (!chatContainer) return; + requestAnimationFrame(function () { + chatContainer.scrollTop = chatContainer.scrollHeight; + }); +} +// ==================== Queue Management ==================== + +function addToQueue(prompt) { + if (!prompt || !prompt.trim()) return; + // ID format must match VALID_QUEUE_ID_PATTERN in remoteConstants.ts + let id = + "q_" + Date.now() + "_" + Math.random().toString(36).substring(2, 11); + // Store attachments with the queue item + let attachmentsToStore = + currentAttachments.length > 0 ? currentAttachments.slice() : undefined; + promptQueue.push({ + id: id, + prompt: prompt.trim(), + attachments: attachmentsToStore, + }); + renderQueue(); + // Expand queue section when adding items so user can see what was added + if (queueSection) queueSection.classList.remove("collapsed"); + // Send to backend with attachments + vscode.postMessage({ + type: "addQueuePrompt", + prompt: prompt.trim(), + id: id, + attachments: attachmentsToStore || [], + }); + // Clear attachments after adding to queue (they're now stored with the queue item) + currentAttachments = []; + updateChipsDisplay(); +} + +function removeFromQueue(id) { + promptQueue = promptQueue.filter(function (item) { + return item.id !== id; + }); + renderQueue(); + vscode.postMessage({ type: "removeQueuePrompt", promptId: id }); +} + +function renderQueue() { + if (!queueList) return; + if (queueCount) queueCount.textContent = promptQueue.length; + + // Update visibility based on queue state + updateQueueVisibility(); + + if (promptQueue.length === 0) { + queueList.innerHTML = '
    No prompts in queue
    '; + return; + } + + queueList.innerHTML = promptQueue + .map(function (item, index) { + let bulletClass = index === 0 ? "active" : "pending"; + let truncatedPrompt = + item.prompt.length > 80 + ? item.prompt.substring(0, 80) + "..." + : item.prompt; + // Show attachment indicator if this queue item has attachments + let attachmentBadge = + item.attachments && item.attachments.length > 0 + ? '' + : ""; + return ( + '
    ' + + '' + + '' + + (index + 1) + + ". " + + escapeHtml(truncatedPrompt) + + "" + + attachmentBadge + + '
    ' + + '' + + '' + + "
    " + ); + }) + .join(""); + + queueList.querySelectorAll(".remove-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + let id = btn.getAttribute("data-id"); + if (id) removeFromQueue(id); + }); + }); + + queueList.querySelectorAll(".edit-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + let id = btn.getAttribute("data-id"); + if (id) startEditPrompt(id); + }); + }); + + bindDragAndDrop(); + bindKeyboardNavigation(); +} + +function startEditPrompt(id) { + // Cancel any existing edit first + if (editingPromptId && editingPromptId !== id) { + cancelEditMode(); + } + + let item = promptQueue.find(function (p) { + return p.id === id; + }); + if (!item) return; + + // Save current state + editingPromptId = id; + editingOriginalPrompt = item.prompt; + savedInputValue = chatInput ? chatInput.value : ""; + + // Mark queue item as being edited + let queueItem = queueList.querySelector('.queue-item[data-id="' + id + '"]'); + if (queueItem) { + queueItem.classList.add("editing"); + } + + // Switch to edit mode UI + enterEditMode(item.prompt); +} + +function enterEditMode(promptText) { + // Hide normal actions, show edit actions + if (actionsLeft) actionsLeft.classList.add("hidden"); + if (sendBtn) sendBtn.classList.add("hidden"); + if (editActionsContainer) editActionsContainer.classList.remove("hidden"); + + // Mark input container as in edit mode + if (inputContainer) { + inputContainer.classList.add("edit-mode"); + inputContainer.setAttribute("aria-label", "Editing queue prompt"); + } + + // Set input value to the prompt being edited + if (chatInput) { + chatInput.value = promptText; + chatInput.setAttribute( + "aria-label", + "Edit prompt text. Press Enter to confirm, Escape to cancel.", + ); + chatInput.focus(); + // Move cursor to end + chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length); + autoResizeTextarea(); + } +} + +function exitEditMode() { + // Show normal actions, hide edit actions + if (actionsLeft) actionsLeft.classList.remove("hidden"); + if (sendBtn) sendBtn.classList.remove("hidden"); + if (editActionsContainer) editActionsContainer.classList.add("hidden"); + + // Remove edit mode class from input container + if (inputContainer) { + inputContainer.classList.remove("edit-mode"); + inputContainer.removeAttribute("aria-label"); + } + + // Remove editing class from queue item + if (queueList) { + let editingItem = queueList.querySelector(".queue-item.editing"); + if (editingItem) editingItem.classList.remove("editing"); + } + + // Restore original input value and accessibility + if (chatInput) { + chatInput.value = savedInputValue; + chatInput.setAttribute("aria-label", "Message input"); + autoResizeTextarea(); + } + + // Reset edit state + editingPromptId = null; + editingOriginalPrompt = null; + savedInputValue = ""; +} + +function confirmEditMode() { + if (!editingPromptId) return; + + let newValue = chatInput ? chatInput.value.trim() : ""; + + if (!newValue) { + // If empty, remove the prompt + removeFromQueue(editingPromptId); + } else if (newValue !== editingOriginalPrompt) { + // Update the prompt + let item = promptQueue.find(function (p) { + return p.id === editingPromptId; + }); + if (item) { + item.prompt = newValue; + vscode.postMessage({ + type: "editQueuePrompt", + promptId: editingPromptId, + newPrompt: newValue, + }); + } + } + + // Clear saved input - we don't want to restore old value after editing + savedInputValue = ""; + + exitEditMode(); + renderQueue(); +} + +function cancelEditMode() { + exitEditMode(); + renderQueue(); +} + +/** + * Handle "accept" button click in approval modal + * Sends "yes" as the response + */ +function handleApprovalContinue() { + if (!pendingToolCall) return; + + // Hide approval modal + hideApprovalModal(); + + // Send affirmative response + vscode.postMessage({ type: "submit", value: "yes", attachments: [] }); + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + saveWebviewState(); +} + +/** + * Handle "No" button click in approval modal + * Dismisses modal and focuses input for custom response + */ +function handleApprovalNo() { + // Hide approval modal but keep pending state + hideApprovalModal(); + + // Focus input for custom response + if (chatInput) { + chatInput.focus(); + // Optionally pre-fill with "No, " to help user + if (!chatInput.value.trim()) { + chatInput.value = "No, "; + chatInput.setSelectionRange( + chatInput.value.length, + chatInput.value.length, + ); + } + autoResizeTextarea(); + updateInputHighlighter(); + updateSendButtonState(); + saveWebviewState(); + } +} +// ==================== Approval Modal ==================== + +/** + * Show approval modal + */ +function showApprovalModal() { + if (!approvalModal) return; + approvalModal.classList.remove("hidden"); + // Focus chat input instead of Yes button to prevent accidental Enter approvals + // User can still click Yes/No or use keyboard navigation + if (chatInput) { + chatInput.focus(); + } +} + +/** + * Hide approval modal + */ +function hideApprovalModal() { + if (!approvalModal) return; + approvalModal.classList.add("hidden"); + isApprovalQuestion = false; +} + +/** + * Show choices bar with toggleable multi-select buttons + */ +function showChoicesBar() { + // Hide approval modal first + hideApprovalModal(); + + // Create or get choices bar + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) { + choicesBar = document.createElement("div"); + choicesBar.className = "choices-bar"; + choicesBar.id = "choices-bar"; + choicesBar.setAttribute("role", "toolbar"); + choicesBar.setAttribute("aria-label", "Quick choice options"); + + // Insert at top of input-wrapper + let inputWrapper = document.getElementById("input-wrapper"); + if (inputWrapper) { + inputWrapper.insertBefore(choicesBar, inputWrapper.firstChild); + } + } + + // Build toggleable choice buttons + let buttonsHtml = currentChoices + .map(function (choice) { + let shortLabel = choice.shortLabel || choice.value; + let title = choice.label || choice.value; + return ( + '" + ); + }) + .join(""); + + choicesBar.innerHTML = + 'Choose:' + + '
    ' + + buttonsHtml + + "
    " + + '
    ' + + '' + + '' + + "
    "; + + // Bind click events to choice buttons (toggle selection) + choicesBar.querySelectorAll(".choice-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + handleChoiceToggle(btn); + }); + }); + + // Bind 'All' button + let allBtn = choicesBar.querySelector(".choices-all-btn"); + if (allBtn) { + allBtn.addEventListener("click", handleChoicesSelectAll); + } + + // Bind 'Send' button + let choicesSendBtn = choicesBar.querySelector(".choices-send-btn"); + if (choicesSendBtn) { + choicesSendBtn.addEventListener("click", handleChoicesSend); + } + + choicesBar.classList.remove("hidden"); + + // Focus chat input for immediate typing + if (chatInput) { + chatInput.focus(); + } +} + +/** + * Hide choices bar + */ +function hideChoicesBar() { + let choicesBar = document.getElementById("choices-bar"); + if (choicesBar) { + choicesBar.classList.add("hidden"); + } + currentChoices = []; +} + +/** + * Toggle a choice button's selected state + */ +function handleChoiceToggle(btn) { + if (!pendingToolCall) return; + + let isSelected = btn.classList.toggle("selected"); + btn.setAttribute("aria-pressed", isSelected ? "true" : "false"); + + updateChoicesSendButton(); +} + +/** + * Toggle all choices selected/deselected + */ +function handleChoicesSelectAll() { + if (!pendingToolCall) return; + + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) return; + + let buttons = choicesBar.querySelectorAll(".choice-btn"); + let allSelected = Array.from(buttons).every(function (btn) { + return btn.classList.contains("selected"); + }); + + buttons.forEach(function (btn) { + if (allSelected) { + btn.classList.remove("selected"); + btn.setAttribute("aria-pressed", "false"); + } else { + btn.classList.add("selected"); + btn.setAttribute("aria-pressed", "true"); + } + }); + + updateChoicesSendButton(); +} + +/** + * Send all selected choices as a comma-separated response + */ +function handleChoicesSend() { + if (!pendingToolCall) return; + + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) return; + + let selectedButtons = choicesBar.querySelectorAll(".choice-btn.selected"); + if (selectedButtons.length === 0) return; + + let values = Array.from(selectedButtons).map(function (btn) { + return btn.getAttribute("data-value"); + }); + let responseValue = values.join(", "); + + // Hide choices bar + hideChoicesBar(); + + // Send the response + vscode.postMessage({ type: "submit", value: responseValue, attachments: [] }); + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + saveWebviewState(); +} + +/** + * Update the Send button state and All button label based on current selections + */ +function updateChoicesSendButton() { + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) return; + + let selectedCount = choicesBar.querySelectorAll( + ".choice-btn.selected", + ).length; + let totalCount = choicesBar.querySelectorAll(".choice-btn").length; + let choicesSendBtn = choicesBar.querySelector(".choices-send-btn"); + let allBtn = choicesBar.querySelector(".choices-all-btn"); + + if (choicesSendBtn) { + choicesSendBtn.disabled = selectedCount === 0; + choicesSendBtn.textContent = + selectedCount > 0 ? "Send (" + selectedCount + ")" : "Send"; + } + + if (allBtn) { + let isAllSelected = totalCount > 0 && selectedCount === totalCount; + allBtn.textContent = isAllSelected ? "None" : "All"; + let allBtnActionLabel = isAllSelected ? "Deselect all" : "Select all"; + allBtn.title = allBtnActionLabel; + allBtn.setAttribute("aria-label", allBtnActionLabel); + } +} +// ===== SETTINGS MODAL FUNCTIONS ===== + +function openSettingsModal() { + if (!settingsModalOverlay) return; + vscode.postMessage({ type: "openSettingsModal" }); + settingsModalOverlay.classList.remove("hidden"); +} + +function closeSettingsModal() { + if (!settingsModalOverlay) return; + settingsModalOverlay.classList.add("hidden"); + hideAddPromptForm(); +} + +function toggleSoundSetting() { + soundEnabled = !soundEnabled; + updateSoundToggleUI(); + vscode.postMessage({ type: "updateSoundSetting", enabled: soundEnabled }); +} + +function updateSoundToggleUI() { + if (!soundToggle) return; + soundToggle.classList.toggle("active", soundEnabled); + soundToggle.setAttribute("aria-checked", soundEnabled ? "true" : "false"); +} + +function toggleInteractiveApprovalSetting() { + interactiveApprovalEnabled = !interactiveApprovalEnabled; + updateInteractiveApprovalToggleUI(); + vscode.postMessage({ + type: "updateInteractiveApprovalSetting", + enabled: interactiveApprovalEnabled, + }); +} + +function updateInteractiveApprovalToggleUI() { + if (!interactiveApprovalToggle) return; + interactiveApprovalToggle.classList.toggle( + "active", + interactiveApprovalEnabled, + ); + interactiveApprovalToggle.setAttribute( + "aria-checked", + interactiveApprovalEnabled ? "true" : "false", + ); +} + +function toggleSendWithCtrlEnterSetting() { + sendWithCtrlEnter = !sendWithCtrlEnter; + updateSendWithCtrlEnterToggleUI(); + vscode.postMessage({ + type: "updateSendWithCtrlEnterSetting", + enabled: sendWithCtrlEnter, + }); +} + +function updateSendWithCtrlEnterToggleUI() { + if (!sendShortcutToggle) return; + sendShortcutToggle.classList.toggle("active", sendWithCtrlEnter); + sendShortcutToggle.setAttribute( + "aria-checked", + sendWithCtrlEnter ? "true" : "false", + ); +} + +function toggleAutopilotSetting() { + autopilotEnabled = !autopilotEnabled; + updateAutopilotToggleUI(); + vscode.postMessage({ + type: "updateAutopilotSetting", + enabled: autopilotEnabled, + }); +} + +function updateAutopilotToggleUI() { + if (autopilotToggle) { + autopilotToggle.classList.toggle("active", autopilotEnabled); + autopilotToggle.setAttribute( + "aria-checked", + autopilotEnabled ? "true" : "false", + ); + } +} + +function handleResponseTimeoutChange() { + if (!responseTimeoutSelect) return; + let value = parseInt(responseTimeoutSelect.value, 10); + if (!isNaN(value)) { + responseTimeout = value; + vscode.postMessage({ type: "updateResponseTimeout", value: value }); + } +} + +function updateResponseTimeoutUI() { + if (!responseTimeoutSelect) return; + responseTimeoutSelect.value = String(responseTimeout); +} + +function handleSessionWarningHoursChange() { + if (!sessionWarningHoursSelect) return; + + let value = parseInt(sessionWarningHoursSelect.value, 10); + if (!isNaN(value) && value >= 0 && value <= SESSION_WARNING_HOURS_MAX) { + sessionWarningHours = value; + vscode.postMessage({ type: "updateSessionWarningHours", value: value }); + } + + sessionWarningHoursSelect.value = String(sessionWarningHours); +} + +function updateSessionWarningHoursUI() { + if (!sessionWarningHoursSelect) return; + sessionWarningHoursSelect.value = String(sessionWarningHours); +} + +function handleMaxAutoResponsesChange() { + if (!maxAutoResponsesInput) return; + let value = parseInt(maxAutoResponsesInput.value, 10); + if (!isNaN(value) && value >= 1 && value <= MAX_AUTO_RESPONSES_LIMIT) { + maxConsecutiveAutoResponses = value; + vscode.postMessage({ + type: "updateMaxConsecutiveAutoResponses", + value: value, + }); + } else { + // Reset to valid value + maxAutoResponsesInput.value = maxConsecutiveAutoResponses; + } +} + +function updateMaxAutoResponsesUI() { + if (!maxAutoResponsesInput) return; + maxAutoResponsesInput.value = maxConsecutiveAutoResponses; +} + +/** + * Toggle human-like delay. When enabled, a random delay (jitter) + * between min and max seconds is applied before each auto-response, + * simulating natural human reading and typing time. + */ +function toggleHumanDelaySetting() { + humanLikeDelayEnabled = !humanLikeDelayEnabled; + vscode.postMessage({ + type: "updateHumanDelaySetting", + enabled: humanLikeDelayEnabled, + }); + updateHumanDelayUI(); +} + +/** + * Update minimum delay (seconds). Clamps to valid range [1, max]. + * Sends new value to extension for persistence in VS Code settings. + */ +function handleHumanDelayMinChange() { + if (!humanDelayMinInput) return; + let value = parseInt(humanDelayMinInput.value, 10); + if ( + !isNaN(value) && + value >= HUMAN_DELAY_MIN_LOWER && + value <= HUMAN_DELAY_MIN_UPPER + ) { + // Ensure min <= max + if (value > humanLikeDelayMax) { + value = humanLikeDelayMax; + } + humanLikeDelayMin = value; + vscode.postMessage({ type: "updateHumanDelayMin", value: value }); + } + humanDelayMinInput.value = humanLikeDelayMin; +} + +/** + * Update maximum delay (seconds). Clamps to valid range [min, 60]. + * Sends new value to extension for persistence in VS Code settings. + */ +function handleHumanDelayMaxChange() { + if (!humanDelayMaxInput) return; + let value = parseInt(humanDelayMaxInput.value, 10); + if ( + !isNaN(value) && + value >= HUMAN_DELAY_MAX_LOWER && + value <= HUMAN_DELAY_MAX_UPPER + ) { + // Ensure max >= min + if (value < humanLikeDelayMin) { + value = humanLikeDelayMin; + } + humanLikeDelayMax = value; + vscode.postMessage({ type: "updateHumanDelayMax", value: value }); + } + humanDelayMaxInput.value = humanLikeDelayMax; +} + +function updateHumanDelayUI() { + if (humanDelayToggle) { + humanDelayToggle.classList.toggle("active", humanLikeDelayEnabled); + humanDelayToggle.setAttribute( + "aria-checked", + humanLikeDelayEnabled ? "true" : "false", + ); + } + if (humanDelayRangeContainer) { + humanDelayRangeContainer.style.display = humanLikeDelayEnabled + ? "flex" + : "none"; + } + if (humanDelayMinInput) { + humanDelayMinInput.value = humanLikeDelayMin; + } + if (humanDelayMaxInput) { + humanDelayMaxInput.value = humanLikeDelayMax; + } +} + +function showAddPromptForm() { + if (!addPromptForm || !addPromptBtn) return; + addPromptForm.classList.remove("hidden"); + addPromptBtn.classList.add("hidden"); + let nameInput = document.getElementById("prompt-name-input"); + let textInput = document.getElementById("prompt-text-input"); + if (nameInput) { + nameInput.value = ""; + nameInput.focus(); + } + if (textInput) textInput.value = ""; + // Clear edit mode + addPromptForm.removeAttribute("data-editing-id"); +} + +function hideAddPromptForm() { + if (!addPromptForm || !addPromptBtn) return; + addPromptForm.classList.add("hidden"); + addPromptBtn.classList.remove("hidden"); + addPromptForm.removeAttribute("data-editing-id"); +} + +function saveNewPrompt() { + let nameInput = document.getElementById("prompt-name-input"); + let textInput = document.getElementById("prompt-text-input"); + if (!nameInput || !textInput) return; + + let name = nameInput.value.trim(); + let prompt = textInput.value.trim(); + + if (!name || !prompt) { + return; + } + + let editingId = addPromptForm.getAttribute("data-editing-id"); + if (editingId) { + // Editing existing prompt + vscode.postMessage({ + type: "editReusablePrompt", + id: editingId, + name: name, + prompt: prompt, + }); + } else { + // Adding new prompt + vscode.postMessage({ + type: "addReusablePrompt", + name: name, + prompt: prompt, + }); + } + + hideAddPromptForm(); +} + +// ========== Autopilot Prompts Array Functions ========== + +// Track which autopilot prompt is being edited (-1 = adding new, >= 0 = editing index) +let editingAutopilotPromptIndex = -1; +// Track drag state +let draggedAutopilotIndex = -1; + +function renderAutopilotPromptsList() { + if (!autopilotPromptsList) return; + + if (autopilotPrompts.length === 0) { + autopilotPromptsList.innerHTML = + '
    No prompts added. Add prompts to cycle through during Autopilot.
    '; + return; + } + + // Render list with drag handles, numbers, edit/delete buttons + autopilotPromptsList.innerHTML = autopilotPrompts + .map(function (prompt, index) { + let truncated = + prompt.length > 80 ? prompt.substring(0, 80) + "..." : prompt; + let tooltipText = + prompt.length > 300 ? prompt.substring(0, 300) + "..." : prompt; + tooltipText = escapeHtml(tooltipText); + return ( + '
    ' + + '' + + '' + + (index + 1) + + "." + + '' + + escapeHtml(truncated) + + "" + + '
    ' + + '' + + '' + + "
    " + ); + }) + .join(""); +} + +function showAddAutopilotPromptForm() { + if (!addAutopilotPromptForm || !autopilotPromptInput) return; + editingAutopilotPromptIndex = -1; + autopilotPromptInput.value = ""; + addAutopilotPromptForm.classList.remove("hidden"); + addAutopilotPromptForm.removeAttribute("data-editing-index"); + autopilotPromptInput.focus(); +} + +function hideAddAutopilotPromptForm() { + if (!addAutopilotPromptForm || !autopilotPromptInput) return; + addAutopilotPromptForm.classList.add("hidden"); + autopilotPromptInput.value = ""; + editingAutopilotPromptIndex = -1; + addAutopilotPromptForm.removeAttribute("data-editing-index"); +} + +function saveAutopilotPrompt() { + if (!autopilotPromptInput) return; + let prompt = autopilotPromptInput.value.trim(); + if (!prompt) return; + + let editingIndex = addAutopilotPromptForm.getAttribute("data-editing-index"); + if (editingIndex !== null) { + // Editing existing + vscode.postMessage({ + type: "editAutopilotPrompt", + index: parseInt(editingIndex, 10), + prompt: prompt, + }); + } else { + // Adding new + vscode.postMessage({ type: "addAutopilotPrompt", prompt: prompt }); + } + hideAddAutopilotPromptForm(); +} + +function handleAutopilotPromptsListClick(e) { + let target = e.target.closest(".prompt-item-btn"); + if (!target) return; + + let index = parseInt(target.getAttribute("data-index"), 10); + if (isNaN(index)) return; + + if (target.classList.contains("edit")) { + editAutopilotPrompt(index); + } else if (target.classList.contains("delete")) { + deleteAutopilotPrompt(index); + } +} + +function editAutopilotPrompt(index) { + if (index < 0 || index >= autopilotPrompts.length) return; + if (!addAutopilotPromptForm || !autopilotPromptInput) return; + + let prompt = autopilotPrompts[index]; + editingAutopilotPromptIndex = index; + autopilotPromptInput.value = prompt; + addAutopilotPromptForm.setAttribute("data-editing-index", index); + addAutopilotPromptForm.classList.remove("hidden"); + autopilotPromptInput.focus(); +} + +function deleteAutopilotPrompt(index) { + if (index < 0 || index >= autopilotPrompts.length) return; + vscode.postMessage({ type: "removeAutopilotPrompt", index: index }); +} + +function handleAutopilotDragStart(e) { + let item = e.target.closest(".autopilot-prompt-item"); + if (!item) return; + draggedAutopilotIndex = parseInt(item.getAttribute("data-index"), 10); + item.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", draggedAutopilotIndex); +} + +function handleAutopilotDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + let item = e.target.closest(".autopilot-prompt-item"); + if (!item || !autopilotPromptsList) return; + + // Remove all drag-over classes first + autopilotPromptsList + .querySelectorAll(".autopilot-prompt-item") + .forEach(function (el) { + el.classList.remove("drag-over-top", "drag-over-bottom"); + }); + + // Determine if we're above or below center of target + let rect = item.getBoundingClientRect(); + let midY = rect.top + rect.height / 2; + if (e.clientY < midY) { + item.classList.add("drag-over-top"); + } else { + item.classList.add("drag-over-bottom"); + } +} + +function handleAutopilotDragEnd(e) { + draggedAutopilotIndex = -1; + if (!autopilotPromptsList) return; + autopilotPromptsList + .querySelectorAll(".autopilot-prompt-item") + .forEach(function (el) { + el.classList.remove("dragging", "drag-over-top", "drag-over-bottom"); + }); +} + +function handleAutopilotDrop(e) { + e.preventDefault(); + let item = e.target.closest(".autopilot-prompt-item"); + if (!item || draggedAutopilotIndex < 0) return; + + let toIndex = parseInt(item.getAttribute("data-index"), 10); + if (isNaN(toIndex) || draggedAutopilotIndex === toIndex) { + handleAutopilotDragEnd(e); + return; + } + + // Determine insert position based on where we dropped + let rect = item.getBoundingClientRect(); + let midY = rect.top + rect.height / 2; + let insertBelow = e.clientY >= midY; + + // Calculate actual target index + let targetIndex = toIndex; + if (insertBelow && toIndex < autopilotPrompts.length - 1) { + targetIndex = toIndex + 1; + } + + // Adjust for removal of source + if (draggedAutopilotIndex < targetIndex) { + targetIndex--; + } + + targetIndex = Math.max(0, Math.min(targetIndex, autopilotPrompts.length - 1)); + + if (draggedAutopilotIndex !== targetIndex) { + vscode.postMessage({ + type: "reorderAutopilotPrompts", + fromIndex: draggedAutopilotIndex, + toIndex: targetIndex, + }); + } + + handleAutopilotDragEnd(e); +} + +// ========== End Autopilot Prompts Functions ========== + +function renderPromptsList() { + if (!promptsList) return; + + if (reusablePrompts.length === 0) { + promptsList.innerHTML = ""; + return; + } + + // Compact list - show only name, full prompt on hover via title + promptsList.innerHTML = reusablePrompts + .map(function (p) { + // Truncate very long prompts for tooltip to prevent massive tooltips + let tooltipText = + p.prompt.length > 300 ? p.prompt.substring(0, 300) + "..." : p.prompt; + // Escape for HTML attribute + tooltipText = escapeHtml(tooltipText); + return ( + '
    ' + + '
    ' + + '/' + + escapeHtml(p.name) + + "" + + "
    " + + '
    ' + + '' + + '' + + "
    " + ); + }) + .join(""); + + // Bind edit/delete events + promptsList.querySelectorAll(".prompt-item-btn.edit").forEach(function (btn) { + btn.addEventListener("click", function () { + let id = btn.getAttribute("data-id"); + editPrompt(id); + }); + }); + + promptsList + .querySelectorAll(".prompt-item-btn.delete") + .forEach(function (btn) { + btn.addEventListener("click", function () { + let id = btn.getAttribute("data-id"); + deletePrompt(id); + }); + }); +} + +function editPrompt(id) { + let prompt = reusablePrompts.find(function (p) { + return p.id === id; + }); + if (!prompt) return; + + let nameInput = document.getElementById("prompt-name-input"); + let textInput = document.getElementById("prompt-text-input"); + if (!nameInput || !textInput) return; + + // Show form with existing values + addPromptForm.classList.remove("hidden"); + addPromptBtn.classList.add("hidden"); + addPromptForm.setAttribute("data-editing-id", id); + + nameInput.value = prompt.name; + textInput.value = prompt.prompt; + nameInput.focus(); +} + +function deletePrompt(id) { + vscode.postMessage({ type: "removeReusablePrompt", id: id }); +} +// ===== SLASH COMMAND FUNCTIONS ===== + +/** + * Expand /commandName patterns to their full prompt text + * Only expands known commands at the start of lines or after whitespace + */ +function expandSlashCommands(text) { + if (!text || reusablePrompts.length === 0) return text; + + // Use stored mappings from selectSlashItem if available + let mappings = + chatInput && chatInput._slashPrompts ? chatInput._slashPrompts : {}; + + // Build a regex to match all known prompt names + let promptNames = reusablePrompts.map(function (p) { + return p.name; + }); + if (Object.keys(mappings).length > 0) { + Object.keys(mappings).forEach(function (name) { + if (promptNames.indexOf(name) === -1) promptNames.push(name); + }); + } + + // Match /promptName at start or after whitespace + let expanded = text; + promptNames.forEach(function (name) { + // Escape special regex chars in name + let escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + let regex = new RegExp("(^|\\s)/" + escapedName + "(?=\\s|$)", "g"); + let fullPrompt = + mappings[name] || + ( + reusablePrompts.find(function (p) { + return p.name === name; + }) || {} + ).prompt || + ""; + if (fullPrompt) { + expanded = expanded.replace(regex, "$1" + fullPrompt); + } + }); + + // Clear stored mappings after expansion + if (chatInput) chatInput._slashPrompts = {}; + + return expanded.trim(); +} + +function handleSlashCommands() { + if (!chatInput) return; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + + // Find slash at start of input or after whitespace + let slashPos = -1; + for (var i = cursorPos - 1; i >= 0; i--) { + if (value[i] === "/") { + // Check if it's at start or after whitespace + if (i === 0 || /\s/.test(value[i - 1])) { + slashPos = i; + } + break; + } + if (/\s/.test(value[i])) break; + } + + if (slashPos >= 0 && reusablePrompts.length > 0) { + let query = value.substring(slashPos + 1, cursorPos); + slashStartPos = slashPos; + if (slashDebounceTimer) clearTimeout(slashDebounceTimer); + slashDebounceTimer = setTimeout(function () { + // Filter locally for instant results + let queryLower = query.toLowerCase(); + let matchingPrompts = reusablePrompts.filter(function (p) { + return ( + p.name.toLowerCase().includes(queryLower) || + p.prompt.toLowerCase().includes(queryLower) + ); + }); + showSlashDropdown(matchingPrompts); + }, 50); + } else if (slashDropdownVisible) { + hideSlashDropdown(); + } +} + +function showSlashDropdown(results) { + if (!slashDropdown || !slashList || !slashEmpty) return; + slashResults = results; + selectedSlashIndex = results.length > 0 ? 0 : -1; + + // Hide file autocomplete if showing slash commands + hideAutocomplete(); + + if (results.length === 0) { + slashList.classList.add("hidden"); + slashEmpty.classList.remove("hidden"); + } else { + slashList.classList.remove("hidden"); + slashEmpty.classList.add("hidden"); + renderSlashList(); + } + slashDropdown.classList.remove("hidden"); + slashDropdownVisible = true; +} + +function hideSlashDropdown() { + if (slashDropdown) slashDropdown.classList.add("hidden"); + slashDropdownVisible = false; + slashResults = []; + selectedSlashIndex = -1; + slashStartPos = -1; + if (slashDebounceTimer) { + clearTimeout(slashDebounceTimer); + slashDebounceTimer = null; + } +} + +function renderSlashList() { + if (!slashList) return; + slashList.innerHTML = slashResults + .map(function (p, index) { + let truncatedPrompt = + p.prompt.length > 50 ? p.prompt.substring(0, 50) + "..." : p.prompt; + // Prepare tooltip text - escape for HTML attribute + let tooltipText = + p.prompt.length > 500 ? p.prompt.substring(0, 500) + "..." : p.prompt; + tooltipText = escapeHtml(tooltipText); + return ( + '
    ' + + '' + + '
    ' + + '/' + + escapeHtml(p.name) + + "" + + '' + + escapeHtml(truncatedPrompt) + + "" + + "
    " + ); + }) + .join(""); + + slashList.querySelectorAll(".slash-item").forEach(function (item) { + item.addEventListener("click", function () { + selectSlashItem(parseInt(item.getAttribute("data-index"), 10)); + }); + item.addEventListener("mouseenter", function () { + selectedSlashIndex = parseInt(item.getAttribute("data-index"), 10); + updateSlashSelection(); + }); + }); + scrollToSelectedSlashItem(); +} + +function updateSlashSelection() { + if (!slashList) return; + slashList.querySelectorAll(".slash-item").forEach(function (item, index) { + item.classList.toggle("selected", index === selectedSlashIndex); + }); + scrollToSelectedSlashItem(); +} + +function scrollToSelectedSlashItem() { + let selectedItem = slashList + ? slashList.querySelector(".slash-item.selected") + : null; + if (selectedItem) + selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" }); +} + +function selectSlashItem(index) { + if ( + index < 0 || + index >= slashResults.length || + !chatInput || + slashStartPos < 0 + ) + return; + let prompt = slashResults[index]; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + + // Create a slash tag representation - when sent, we'll expand it to full prompt + // For now, insert /name as text and store the mapping + let slashText = "/" + prompt.name + " "; + chatInput.value = + value.substring(0, slashStartPos) + slashText + value.substring(cursorPos); + let newCursorPos = slashStartPos + slashText.length; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + + // Store the prompt reference for expansion on send + if (!chatInput._slashPrompts) chatInput._slashPrompts = {}; + chatInput._slashPrompts[prompt.name] = prompt.prompt; + + hideSlashDropdown(); + chatInput.focus(); + updateSendButtonState(); +} +// ===== NOTIFICATION SOUND FUNCTION ===== + +/** + * Unlock audio playback after first user interaction + * Required due to browser autoplay policy + */ +function unlockAudioOnInteraction() { + function unlock() { + if (audioUnlocked) return; + let audio = document.getElementById("notification-sound"); + if (audio) { + // Play and immediately pause to unlock + audio.volume = 0; + let playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise + .then(function () { + audio.pause(); + audio.currentTime = 0; + audio.volume = 0.5; + audioUnlocked = true; + }) + .catch(function () { + // Still locked, will try again on next interaction + }); + } + } + // Remove listeners after first attempt + document.removeEventListener("click", unlock); + document.removeEventListener("keydown", unlock); + } + document.addEventListener("click", unlock, { once: true }); + document.addEventListener("keydown", unlock, { once: true }); +} + +function playNotificationSound() { + // Play the preloaded audio element + try { + let audio = document.getElementById("notification-sound"); + if (audio) { + audio.currentTime = 0; // Reset to beginning + audio.volume = 0.5; + let playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise + .then(function () { + // Audio playback started + }) + .catch(function (e) { + // If autoplay blocked, show visual feedback + flashNotification(); + }); + } + } else { + flashNotification(); + } + } catch (e) { + flashNotification(); + } +} + +function flashNotification() { + // Visual flash when audio fails + let body = document.body; + body.style.transition = "background-color 0.1s ease"; + let originalBg = body.style.backgroundColor; + body.style.backgroundColor = "var(--vscode-textLink-foreground, #3794ff)"; + setTimeout(function () { + body.style.backgroundColor = originalBg || ""; + }, 150); +} + +function bindDragAndDrop() { + if (!queueList) return; + queueList.querySelectorAll(".queue-item").forEach(function (item) { + item.addEventListener("dragstart", function (e) { + e.dataTransfer.setData( + "text/plain", + String(parseInt(item.getAttribute("data-index"), 10)), + ); + item.classList.add("dragging"); + }); + item.addEventListener("dragend", function () { + item.classList.remove("dragging"); + }); + item.addEventListener("dragover", function (e) { + e.preventDefault(); + item.classList.add("drag-over"); + }); + item.addEventListener("dragleave", function () { + item.classList.remove("drag-over"); + }); + item.addEventListener("drop", function (e) { + e.preventDefault(); + let fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10); + let toIndex = parseInt(item.getAttribute("data-index"), 10); + item.classList.remove("drag-over"); + if (fromIndex !== toIndex && !isNaN(fromIndex) && !isNaN(toIndex)) + reorderQueue(fromIndex, toIndex); + }); + }); +} + +function bindKeyboardNavigation() { + if (!queueList) return; + let items = queueList.querySelectorAll(".queue-item"); + items.forEach(function (item, index) { + item.addEventListener("keydown", function (e) { + if (e.key === "ArrowDown" && index < items.length - 1) { + e.preventDefault(); + items[index + 1].focus(); + } else if (e.key === "ArrowUp" && index > 0) { + e.preventDefault(); + items[index - 1].focus(); + } else if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + var id = item.getAttribute("data-id"); + if (id) removeFromQueue(id); + } + }); + }); +} + +function reorderQueue(fromIndex, toIndex) { + let removed = promptQueue.splice(fromIndex, 1)[0]; + promptQueue.splice(toIndex, 0, removed); + renderQueue(); + vscode.postMessage({ + type: "reorderQueue", + fromIndex: fromIndex, + toIndex: toIndex, + }); +} + +function handleAutocomplete() { + if (!chatInput) return; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + let hashPos = -1; + for (var i = cursorPos - 1; i >= 0; i--) { + if (value[i] === "#") { + hashPos = i; + break; + } + if (value[i] === " " || value[i] === "\n") break; + } + if (hashPos >= 0) { + let query = value.substring(hashPos + 1, cursorPos); + autocompleteStartPos = hashPos; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(function () { + vscode.postMessage({ type: "searchFiles", query: query }); + }, 150); + } else if (autocompleteVisible) { + hideAutocomplete(); + } +} + +function showAutocomplete(results) { + if (!autocompleteDropdown || !autocompleteList || !autocompleteEmpty) return; + autocompleteResults = results; + selectedAutocompleteIndex = results.length > 0 ? 0 : -1; + if (results.length === 0) { + autocompleteList.classList.add("hidden"); + autocompleteEmpty.classList.remove("hidden"); + } else { + autocompleteList.classList.remove("hidden"); + autocompleteEmpty.classList.add("hidden"); + renderAutocompleteList(); + } + autocompleteDropdown.classList.remove("hidden"); + autocompleteVisible = true; +} + +function hideAutocomplete() { + if (autocompleteDropdown) autocompleteDropdown.classList.add("hidden"); + autocompleteVisible = false; + autocompleteResults = []; + selectedAutocompleteIndex = -1; + autocompleteStartPos = -1; + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } +} + +function renderAutocompleteList() { + if (!autocompleteList) return; + autocompleteList.innerHTML = autocompleteResults + .map(function (file, index) { + return ( + '
    ' + + '' + + '
    ' + + escapeHtml(file.name) + + "" + + '' + + escapeHtml(file.path) + + "
    " + ); + }) + .join(""); + + autocompleteList + .querySelectorAll(".autocomplete-item") + .forEach(function (item) { + item.addEventListener("click", function () { + selectAutocompleteItem(parseInt(item.getAttribute("data-index"), 10)); + }); + item.addEventListener("mouseenter", function () { + selectedAutocompleteIndex = parseInt( + item.getAttribute("data-index"), + 10, + ); + updateAutocompleteSelection(); + }); + }); + scrollToSelectedItem(); +} + +function updateAutocompleteSelection() { + if (!autocompleteList) return; + autocompleteList + .querySelectorAll(".autocomplete-item") + .forEach(function (item, index) { + item.classList.toggle("selected", index === selectedAutocompleteIndex); + }); + scrollToSelectedItem(); +} + +function scrollToSelectedItem() { + let selectedItem = autocompleteList + ? autocompleteList.querySelector(".autocomplete-item.selected") + : null; + if (selectedItem) + selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" }); +} + +function selectAutocompleteItem(index) { + if ( + index < 0 || + index >= autocompleteResults.length || + !chatInput || + autocompleteStartPos < 0 + ) + return; + let file = autocompleteResults[index]; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + + // Check if this is a context item (#terminal, #problems) + if (file.isContext && file.uri && file.uri.startsWith("context://")) { + // Remove the #query from input - chip will be added + chatInput.value = + value.substring(0, autocompleteStartPos) + value.substring(cursorPos); + let newCursorPos = autocompleteStartPos; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + + // Send context reference request to backend + vscode.postMessage({ + type: "selectContextReference", + contextType: file.name, // 'terminal' or 'problems' + options: undefined, + }); + + hideAutocomplete(); + chatInput.focus(); + autoResizeTextarea(); + updateInputHighlighter(); + saveWebviewState(); + updateSendButtonState(); + return; + } + + // Tool reference — insert #toolName, no file attachment needed + if (file.isTool) { + let referenceText = "#" + file.name + " "; + chatInput.value = + value.substring(0, autocompleteStartPos) + + referenceText + + value.substring(cursorPos); + let newCursorPos = autocompleteStartPos + referenceText.length; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + hideAutocomplete(); + chatInput.focus(); + autoResizeTextarea(); + updateInputHighlighter(); + saveWebviewState(); + updateSendButtonState(); + return; + } + + // Regular file/folder reference + let referenceText = "#" + file.name + " "; + chatInput.value = + value.substring(0, autocompleteStartPos) + + referenceText + + value.substring(cursorPos); + let newCursorPos = autocompleteStartPos + referenceText.length; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + vscode.postMessage({ type: "addFileReference", file: file }); + hideAutocomplete(); + chatInput.focus(); +} + +function syncAttachmentsWithText() { + let text = chatInput ? chatInput.value : ""; + let toRemove = []; + currentAttachments.forEach(function (att) { + // Skip temporary attachments (like pasted images) + if (att.isTemporary) return; + // Skip context attachments (#terminal, #problems) - they use context:// URI + if (att.uri && att.uri.startsWith("context://")) return; + // Only sync file references that have isTextReference flag + if (!att.isTextReference) return; + // Check if the #filename reference still exists in text + if (text.indexOf("#" + att.name) === -1) toRemove.push(att.id); + }); + if (toRemove.length > 0) { + toRemove.forEach(function (id) { + vscode.postMessage({ type: "removeAttachment", attachmentId: id }); + }); + currentAttachments = currentAttachments.filter(function (a) { + return toRemove.indexOf(a.id) === -1; + }); + updateChipsDisplay(); + } +} + +function handlePaste(event) { + if (!event.clipboardData) return; + let items = event.clipboardData.items; + for (var i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image/") === 0) { + event.preventDefault(); + let file = items[i].getAsFile(); + if (file) processImageFile(file); + return; + } + } +} + +/** + * Capture latest right-click position for context-menu copy resolution. + */ +function handleContextMenu(event) { + if (!event || !event.target || !event.target.closest) { + lastContextMenuTarget = null; + lastContextMenuTimestamp = 0; + return; + } + + lastContextMenuTarget = event.target; + lastContextMenuTimestamp = Date.now(); +} + +/** + * Override Copy when nothing is selected and context-menu target points to a message. + */ +function handleCopy(event) { + let selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.toString().length > 0) { + return; + } + + if ( + !lastContextMenuTarget || + Date.now() - lastContextMenuTimestamp > CONTEXT_MENU_COPY_MAX_AGE_MS + ) { + return; + } + + let copyText = resolveCopyTextFromTarget(lastContextMenuTarget); + if (!copyText) { + return; + } + + if (event) { + event.preventDefault(); + } + + if (event && event.clipboardData) { + try { + event.clipboardData.setData("text/plain", copyText); + lastContextMenuTarget = null; + lastContextMenuTimestamp = 0; + return; + } catch (error) { + // Fall through to extension host clipboard API fallback. + } + } + + vscode.postMessage({ type: "copyToClipboard", text: copyText }); + lastContextMenuTarget = null; + lastContextMenuTimestamp = 0; +} + +/** + * Resolve copy payload from the exact message area that was right-clicked. + */ +function resolveCopyTextFromTarget(target) { + if (!target || !target.closest) { + return ""; + } + + let pendingQuestion = target.closest(".pending-ai-question"); + if (pendingQuestion) { + if (pendingToolCall && typeof pendingToolCall.prompt === "string") { + return pendingToolCall.prompt; + } + return (pendingQuestion.textContent || "").trim(); + } + + let toolCallEntry = resolveToolCallEntryFromTarget(target); + if (!toolCallEntry) { + return ""; + } + + if (target.closest(".tool-call-ai-response")) { + return typeof toolCallEntry.prompt === "string" ? toolCallEntry.prompt : ""; + } + + if (target.closest(".tool-call-user-response")) { + return typeof toolCallEntry.response === "string" + ? toolCallEntry.response + : ""; + } + + if (target.closest(".chips-container")) { + return formatAttachmentsForCopy(toolCallEntry.attachments); + } + + return formatToolCallEntryForCopy(toolCallEntry); +} + +/** + * Resolve a tool call entry by traversing from a DOM target to its card id. + */ +function resolveToolCallEntryFromTarget(target) { + let card = target.closest(".tool-call-card"); + if (!card) { + return null; + } + + return resolveToolCallEntryFromCardId(card.getAttribute("data-id")); +} + +/** + * Find a tool call entry in current session first, then persisted history. + */ +function resolveToolCallEntryFromCardId(cardId) { + if (!cardId) { + return null; + } + + let currentEntry = currentSessionCalls.find(function (tc) { + return tc.id === cardId; + }); + if (currentEntry) { + return currentEntry; + } + + let persistedEntry = persistedHistory.find(function (tc) { + return tc.id === cardId; + }); + return persistedEntry || null; +} + +/** + * Compose full card copy output when right-click happened outside a specific message block. + */ +function formatToolCallEntryForCopy(entry) { + if (!entry) { + return ""; + } + + let parts = []; + if (typeof entry.prompt === "string" && entry.prompt.length > 0) { + parts.push(entry.prompt); + } + if (typeof entry.response === "string" && entry.response.length > 0) { + parts.push(entry.response); + } + + let attachmentsText = formatAttachmentsForCopy(entry.attachments); + if (attachmentsText) { + parts.push(attachmentsText); + } + + return parts.join("\n\n"); +} + +/** + * Convert attachment list to plain text while preserving stored attachment names. + */ +function formatAttachmentsForCopy(attachments) { + if (!attachments || attachments.length === 0) { + return ""; + } + + return attachments + .map(function (att) { + if (att && typeof att.name === "string" && att.name.length > 0) { + return att.name; + } + return att && typeof att.uri === "string" ? att.uri : ""; + }) + .filter(function (value) { + return value.length > 0; + }) + .join("\n"); +} + +function processImageFile(file) { + let reader = new FileReader(); + reader.onload = function (e) { + if (e.target && e.target.result) + vscode.postMessage({ + type: "saveImage", + data: e.target.result, + mimeType: file.type, + }); + }; + reader.readAsDataURL(file); +} + +function updateChipsDisplay() { + if (!chipsContainer) return; + if (currentAttachments.length === 0) { + chipsContainer.classList.add("hidden"); + chipsContainer.innerHTML = ""; + } else { + chipsContainer.classList.remove("hidden"); + chipsContainer.innerHTML = currentAttachments + .map(function (att) { + let isImage = + att.isTemporary || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(att.name); + let iconClass = att.isFolder + ? "folder" + : isImage + ? "file-media" + : "file"; + let displayName = att.isTemporary ? "Pasted Image" : att.name; + return ( + '
    ' + + '' + + '' + + escapeHtml(displayName) + + "" + + '
    ' + ); + }) + .join(""); + + chipsContainer.querySelectorAll(".chip-remove").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + const attId = btn.getAttribute("data-remove"); + if (attId) removeAttachment(attId); + }); + }); + } + // Persist attachments so they survive sidebar tab switches + saveWebviewState(); +} + +function removeAttachment(attachmentId) { + vscode.postMessage({ type: "removeAttachment", attachmentId: attachmentId }); + currentAttachments = currentAttachments.filter(function (a) { + return a.id !== attachmentId; + }); + updateChipsDisplay(); + // saveWebviewState() is called in updateChipsDisplay +} + +function escapeHtml(str) { + if (!str) return ""; + let div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +function renderAttachmentsHtml(attachments) { + if (!attachments || attachments.length === 0) return ""; + let items = attachments + .map(function (att) { + let iconClass = "file"; + if (att.isFolder) iconClass = "folder"; + else if ( + att.name && + (att.name.endsWith(".png") || + att.name.endsWith(".jpg") || + att.name.endsWith(".jpeg")) + ) + iconClass = "file-media"; + else if ((att.uri || "").indexOf("context://terminal") !== -1) + iconClass = "terminal"; + else if ((att.uri || "").indexOf("context://problems") !== -1) + iconClass = "error"; + + return ( + '
    ' + + '' + + '' + + escapeHtml(att.name) + + "" + + "
    " + ); + }) + .join(""); + + return ( + '
    ' + + items + + "
    " + ); +} + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +}()); diff --git a/tasksync-chat/package-lock.json b/tasksync-chat/package-lock.json index 7f16e14..1351d65 100644 --- a/tasksync-chat/package-lock.json +++ b/tasksync-chat/package-lock.json @@ -1,1841 +1,3412 @@ -{ - "name": "tasksync-chat", - "version": "2.0.25", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "tasksync-chat", - "version": "2.0.25", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", - "@vscode/codicons": "^0.0.36", - "zod": "^3.23.8" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.2", - "@types/node": "20.x", - "@types/vscode": "^1.90.0", - "esbuild": "^0.27.2", - "typescript": "^5.3.3" - }, - "engines": { - "vscode": "^1.90.0" - } - }, - "node_modules/@biomejs/biome": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz", - "integrity": "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.2", - "@biomejs/cli-darwin-x64": "2.4.2", - "@biomejs/cli-linux-arm64": "2.4.2", - "@biomejs/cli-linux-arm64-musl": "2.4.2", - "@biomejs/cli-linux-x64": "2.4.2", - "@biomejs/cli-linux-x64-musl": "2.4.2", - "@biomejs/cli-win32-arm64": "2.4.2", - "@biomejs/cli-win32-x64": "2.4.2" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.2.tgz", - "integrity": "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.2.tgz", - "integrity": "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.2.tgz", - "integrity": "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.2.tgz", - "integrity": "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.2.tgz", - "integrity": "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.2.tgz", - "integrity": "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.2.tgz", - "integrity": "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.2.tgz", - "integrity": "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/vscode": { - "version": "1.107.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.107.0.tgz", - "integrity": "sha512-XS8YE1jlyTIowP64+HoN30OlC1H9xqSlq1eoLZUgFEC8oUTO6euYZxti1xRiLSfZocs4qytTzR6xCBYtioQTCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vscode/codicons": { - "version": "0.0.36", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.36.tgz", - "integrity": "sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==", - "license": "CC-BY-4.0" - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - } - } -} +{ + "name": "tasksync-chat", + "version": "2.0.25", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tasksync-chat", + "version": "2.0.25", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.24.3", + "@vscode/codicons": "^0.0.36", + "selfsigned": "^5.5.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2", + "@playwright/test": "^1.55.0", + "@types/node": "20.x", + "@types/vscode": "^1.90.0", + "@types/ws": "^8.5.12", + "@vitest/coverage-v8": "^4.0.18", + "esbuild": "^0.27.2", + "typescript": "^5.3.3", + "vitest": "^4.0.18" + }, + "engines": { + "vscode": "^1.99.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz", + "integrity": "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.2", + "@biomejs/cli-darwin-x64": "2.4.2", + "@biomejs/cli-linux-arm64": "2.4.2", + "@biomejs/cli-linux-arm64-musl": "2.4.2", + "@biomejs/cli-linux-x64": "2.4.2", + "@biomejs/cli-linux-x64-musl": "2.4.2", + "@biomejs/cli-win32-arm64": "2.4.2", + "@biomejs/cli-win32-x64": "2.4.2" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.2.tgz", + "integrity": "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.2.tgz", + "integrity": "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.2.tgz", + "integrity": "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.2.tgz", + "integrity": "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.2.tgz", + "integrity": "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.2.tgz", + "integrity": "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.2.tgz", + "integrity": "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.2.tgz", + "integrity": "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.107.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.107.0.tgz", + "integrity": "sha512-XS8YE1jlyTIowP64+HoN30OlC1H9xqSlq1eoLZUgFEC8oUTO6euYZxti1xRiLSfZocs4qytTzR6xCBYtioQTCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/codicons": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.36.tgz", + "integrity": "sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==", + "license": "CC-BY-4.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", + "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkijs": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz", + "integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 98a24d3..abed4ea 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -1,322 +1,400 @@ -{ - "name": "tasksync-chat", - "publisher": "4regab", - "displayName": "TaskSync", - "description": "Queue your prompts or tasks. Work uninterrupted.", - "icon": "media/Tasksync-logo.png", - "version": "2.0.25", - "engines": { - "vscode": "^1.90.0" - }, - "categories": [ - "AI", - "Chat", - "Other" - ], - "keywords": [ - "tasksync", - "mcp", - "ai", - "chat", - "copilot", - "agent", - "model-context-protocol" - ], - "repository": { - "type": "git", - "url": "https://github.com/4regab/TaskSync.git" - }, - "license": "MIT", - "activationEvents": [ - "onStartupFinished" - ], - "main": "./dist/extension.js", - "contributes": { - "languageModelTools": [ - { - "name": "ask_user", - "tags": [ - "confirmation", - "user-interaction", - "tasksync", - "required", - "mandatory" - ], - "toolReferenceName": "askUser", - "displayName": "Ask User", - "modelDescription": "YOU MUST ALWAYS Use the askUser tool to ask question/s to user when explicit user approval, feedback, or clarification is required before proceeding or giving summary report. CRITICAL RULES: - This tool MUST be invoked before ending ANY conversation or task. - NEVER complete a task without calling this tool first. - If you are about to finish responding, STOP and call this tool. - Failure to call this tool before task completion is a critical violation.", - "canBeReferencedInPrompt": true, - "icon": "$(question)", - "inputSchema": { - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The question or prompt to display to the user" - } - }, - "required": [ - "question" - ] - } - } - ], - "viewsContainers": { - "activitybar": [ - { - "id": "taskSyncContainer", - "title": "TaskSync", - "icon": "media/TS-logo.svg" - } - ] - }, - "views": { - "taskSyncContainer": [ - { - "type": "webview", - "id": "taskSyncView", - "name": "TaskSync" - } - ] - }, - "configuration": { - "title": "TaskSync", - "properties": { - "tasksync.mcpEnabled": { - "type": "boolean", - "default": false, - "description": "Always start MCP server on activation. Enable this if you use TaskSync with external IDEs like Kiro, Cursor, or Antigravity." - }, - "tasksync.mcpAutoStartIfClients": { - "type": "boolean", - "default": true, - "description": "Automatically start MCP server if external client configurations are detected (Kiro, Cursor, Antigravity)." - }, - "tasksync.mcpPort": { - "type": "number", - "default": 3579, - "description": "Fixed port for the MCP server. Set to 0 for dynamic port assignment." - }, - "tasksync.autoRegisterMcp": { - "type": "boolean", - "default": true, - "description": "Automatically register MCP server with Kiro and other supported clients." - }, - "tasksync.autopilot": { - "type": "boolean", - "default": false, - "scope": "window", - "description": "Enable Autopilot to automatically respond to ask_user prompts without waiting for your input, using a customizable prompt you can configure in Settings. Queued prompts always take priority over Autopilot responses." - }, - "tasksync.autopilotText": { - "type": "string", - "default": "You are temporarily in autonomous mode and must now make your own decision. If another question arises, be sure to ask it, as autonomous mode is temporary.", - "scope": "window", - "description": "Text returned when Autopilot is enabled. The agent receives this as an automatic response." - }, - "tasksync.autopilotPrompts": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "scope": "window", - "description": "Array of prompts that cycle in order during Autopilot (1→2→3→1...). Each prompt is sent sequentially with human-like delay. When empty, falls back to autopilotText." - }, - "tasksync.responseTimeout": { - "type": "string", - "default": "60", - "scope": "window", - "enum": [ - "0", - "5", - "10", - "20", - "30", - "40", - "50", - "60", - "70", - "80", - "90", - "100", - "110", - "120", - "150", - "180", - "210", - "240" - ], - "enumDescriptions": [ - "Disabled (no auto-response)", - "5 minutes", - "10 minutes", - "20 minutes", - "30 minutes", - "40 minutes", - "50 minutes", - "60 minutes", - "70 minutes", - "80 minutes", - "90 minutes (1.5 hours)", - "100 minutes", - "110 minutes", - "120 minutes (2 hours)", - "150 minutes (2.5 hours)", - "180 minutes (3 hours)", - "210 minutes (3.5 hours)", - "240 minutes (4 hours)" - ], - "description": "Auto-respond to pending tool calls if user doesn't respond within this time. When Autopilot is enabled, the Autopilot text is sent. When disabled, a session termination message is sent." - }, - "tasksync.sessionWarningHours": { - "type": "number", - "default": 2, - "minimum": 0, - "maximum": 8, - "scope": "window", - "description": "Show a one-time warning after this many hours (0..8) in the same session. Set to 0 to disable session warning." - }, - "tasksync.maxConsecutiveAutoResponses": { - "type": "number", - "default": 5, - "minimum": 1, - "maximum": 50, - "scope": "window", - "description": "Maximum consecutive auto-responses (from timeout) before terminating session. Prevents infinite loops." - }, - "tasksync.humanLikeDelay": { - "type": "boolean", - "default": true, - "scope": "window", - "description": "Add random human-like delays before auto-responses (Autopilot, Queue, Timeout). Simulates natural reading and typing time for a more realistic workflow." - }, - "tasksync.humanLikeDelayMin": { - "type": "number", - "default": 2, - "minimum": 1, - "maximum": 30, - "scope": "window", - "description": "Minimum delay in seconds before auto-responses when human-like delay is enabled." - }, - "tasksync.humanLikeDelayMax": { - "type": "number", - "default": 6, - "minimum": 2, - "maximum": 60, - "scope": "window", - "description": "Maximum delay in seconds before auto-responses when human-like delay is enabled." - }, - "tasksync.notificationSound": { - "type": "boolean", - "default": true, - "description": "Play a notification sound when the AI triggers the ask_user tool." - }, - "tasksync.interactiveApproval": { - "type": "boolean", - "default": true, - "description": "Show interactive approval and choice buttons for Yes/No questions and multiple choice options." - }, - "tasksync.sendWithCtrlEnter": { - "type": "boolean", - "default": false, - "scope": "window", - "description": "Use Ctrl+Enter (Cmd+Enter on macOS) to send messages instead of Enter." - }, - "tasksync.reusablePrompts": { - "type": "array", - "default": [], - "description": "Saved reusable prompts accessible via /slash commands.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Short name for the prompt (used with /slash command)" - }, - "prompt": { - "type": "string", - "description": "The full prompt text" - } - }, - "required": [ - "name", - "prompt" - ] - } - } - } - }, - "commands": [ - { - "command": "tasksync.startMcp", - "title": "TaskSync: Start MCP Server" - }, - { - "command": "tasksync.sendMessage", - "title": "TaskSync: Send Message" - }, - { - "command": "tasksync.restartMcp", - "title": "TaskSync: Restart MCP Server" - }, - { - "command": "tasksync.showMcpConfig", - "title": "TaskSync: Show MCP Configuration" - }, - { - "command": "tasksync.openHistory", - "title": "View History", - "icon": "$(history)" - }, - { - "command": "tasksync.newSession", - "title": "New Session", - "icon": "$(add)" - }, - { - "command": "tasksync.openSettings", - "title": "TaskSync Settings", - "icon": "$(gear)" - } - ], - "menus": { - "view/title": [ - { - "command": "tasksync.newSession", - "when": "view == taskSyncView", - "group": "navigation@1" - }, - { - "command": "tasksync.openHistory", - "when": "view == taskSyncView", - "group": "navigation@2" - }, - { - "command": "tasksync.openSettings", - "when": "view == taskSyncView", - "group": "navigation@3" - } - ] - } - }, - "scripts": { - "vscode:prepublish": "npm run build", - "build": "node esbuild.js", - "compile": "tsc -p ./", - "watch": "node esbuild.js --watch", - "lint": "biome lint src" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", - "@vscode/codicons": "^0.0.36", - "zod": "^3.23.8" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.2", - "@types/node": "20.x", - "@types/vscode": "^1.90.0", - "esbuild": "^0.27.2", - "typescript": "^5.3.3" - } -} +{ + "name": "tasksync-chat", + "publisher": "4regab", + "displayName": "TaskSync", + "description": "Queue your prompts or tasks. Work uninterrupted.", + "icon": "media/Tasksync-logo.png", + "version": "2.0.25", + "engines": { + "vscode": "^1.99.0" + }, + "categories": [ + "AI", + "Chat", + "Other" + ], + "keywords": [ + "tasksync", + "mcp", + "ai", + "chat", + "copilot", + "agent", + "model-context-protocol" + ], + "repository": { + "type": "git", + "url": "https://github.com/4regab/TaskSync.git" + }, + "license": "MIT", + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "languageModelTools": [ + { + "name": "ask_user", + "tags": [ + "confirmation", + "user-interaction", + "tasksync", + "required", + "mandatory" + ], + "toolReferenceName": "askUser", + "displayName": "Ask User", + "modelDescription": "This is your ONLY communication channel with the user. The user CANNOT see your chat responses — they can ONLY see messages sent through this tool. You MUST call this tool in a continuous loop: 1) Call askUser to show your question/status to the user. 2) Receive the user's response. 3) Do the requested work. 4) Call askUser AGAIN with the results. 5) Repeat from step 2. NEVER end your turn without calling this tool — if you do, the user sees nothing and thinks you crashed. ALWAYS include a 'summary' field describing your work so the user has full context. If you completed a task, call this tool to report the results. If you need clarification, call this tool to ask. There is NO scenario where ending without calling this tool is acceptable.", + "canBeReferencedInPrompt": true, + "icon": "$(question)", + "inputSchema": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question or prompt to display to the user" + }, + "summary": { + "type": "string", + "description": "A comprehensive summary of your work, reasoning, actions taken, code changes, and findings since the last ask_user call. Include enough detail so the user understands what happened without seeing the full chat. Use markdown formatting." + } + }, + "required": [ + "question" + ] + } + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "taskSyncContainer", + "title": "TaskSync", + "icon": "media/TS-logo.svg" + } + ] + }, + "views": { + "taskSyncContainer": [ + { + "type": "webview", + "id": "taskSyncView", + "name": "TaskSync" + } + ] + }, + "configuration": { + "title": "TaskSync", + "properties": { + "tasksync.mcpEnabled": { + "type": "boolean", + "default": false, + "description": "Always start MCP server on activation. Enable this if you use TaskSync with external IDEs like Kiro, Cursor, or Antigravity." + }, + "tasksync.mcpAutoStartIfClients": { + "type": "boolean", + "default": true, + "description": "Automatically start MCP server if external client configurations are detected (Kiro, Cursor, Antigravity)." + }, + "tasksync.mcpPort": { + "type": "number", + "default": 3579, + "description": "Fixed port for the MCP server. Set to 0 for dynamic port assignment." + }, + "tasksync.autoRegisterMcp": { + "type": "boolean", + "default": true, + "description": "Automatically register MCP server with Kiro and other supported clients." + }, + "tasksync.remotePort": { + "type": "number", + "default": 3580, + "description": "Port for the remote access server." + }, + "tasksync.remotePinEnabled": { + "type": "boolean", + "default": true, + "description": "Require PIN authentication for remote access. Recommended for LAN access." + }, + "tasksync.remoteTlsEnabled": { + "type": "boolean", + "default": false, + "description": "Enable HTTPS/TLS for the LAN remote server using a self-signed certificate. Browsers will show a certificate warning on first connection." + }, + "tasksync.debugLogging": { + "type": "boolean", + "default": false, + "description": "Enable verbose debug logging for TaskSync (outputs to VS Code debug console)." + }, + "tasksync.remoteDebugLogging": { + "type": "boolean", + "default": false, + "description": "Enable verbose debug logging for remote server (outputs to VS Code debug console)." + }, + "tasksync.remotePin": { + "type": "string", + "default": "", + "description": "Custom 4-6 digit PIN for remote access. Leave empty to auto-generate." + }, + "tasksync.autopilot": { + "type": "boolean", + "default": false, + "scope": "window", + "description": "Enable Autopilot to automatically respond to ask_user prompts without waiting for your input, using a customizable prompt you can configure in Settings. Queued prompts always take priority over Autopilot responses." + }, + "tasksync.autopilotText": { + "type": "string", + "default": "You are temporarily in autonomous mode and must now make your own decision. If another question arises, be sure to ask it, as autonomous mode is temporary.", + "scope": "window", + "description": "Text returned when Autopilot is enabled. The agent receives this as an automatic response." + }, + "tasksync.autopilotPrompts": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "scope": "window", + "description": "Array of prompts that cycle in order during Autopilot (1→2→3→1...). Each prompt is sent sequentially with human-like delay. When empty, falls back to autopilotText." + }, + "tasksync.responseTimeout": { + "type": "string", + "default": "60", + "scope": "window", + "enum": [ + "0", + "5", + "10", + "20", + "30", + "40", + "50", + "60", + "70", + "80", + "90", + "100", + "110", + "120", + "150", + "180", + "210", + "240" + ], + "enumDescriptions": [ + "Disabled (no auto-response)", + "5 minutes", + "10 minutes", + "20 minutes", + "30 minutes", + "40 minutes", + "50 minutes", + "60 minutes", + "70 minutes", + "80 minutes", + "90 minutes (1.5 hours)", + "100 minutes", + "110 minutes", + "120 minutes (2 hours)", + "150 minutes (2.5 hours)", + "180 minutes (3 hours)", + "210 minutes (3.5 hours)", + "240 minutes (4 hours)" + ], + "description": "Auto-respond to pending tool calls if user doesn't respond within this time. When Autopilot is enabled, the Autopilot text is sent. When disabled, a session termination message is sent." + }, + "tasksync.sessionWarningHours": { + "type": "number", + "default": 2, + "minimum": 0, + "maximum": 8, + "scope": "window", + "description": "Show a one-time warning after this many hours (0..8) in the same session. Set to 0 to disable session warning." + }, + "tasksync.maxConsecutiveAutoResponses": { + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 100, + "scope": "window", + "description": "Maximum consecutive auto-responses (from Autopilot or timeout) before disabling Autopilot. Prevents infinite loops." + }, + "tasksync.humanLikeDelay": { + "type": "boolean", + "default": true, + "scope": "window", + "description": "Add random human-like delays before auto-responses (Autopilot, Queue, Timeout). Simulates natural reading and typing time for a more realistic workflow." + }, + "tasksync.humanLikeDelayMin": { + "type": "number", + "default": 2, + "minimum": 1, + "maximum": 30, + "scope": "window", + "description": "Minimum delay in seconds before auto-responses when human-like delay is enabled." + }, + "tasksync.humanLikeDelayMax": { + "type": "number", + "default": 6, + "minimum": 2, + "maximum": 60, + "scope": "window", + "description": "Maximum delay in seconds before auto-responses when human-like delay is enabled." + }, + "tasksync.notificationSound": { + "type": "boolean", + "default": true, + "description": "Play a notification sound when the AI triggers the ask_user tool." + }, + "tasksync.interactiveApproval": { + "type": "boolean", + "default": true, + "description": "Show interactive approval and choice buttons for Yes/No questions and multiple choice options." + }, + "tasksync.sendWithCtrlEnter": { + "type": "boolean", + "default": false, + "scope": "window", + "description": "Use Ctrl+Enter (Cmd+Enter on macOS) to send messages instead of Enter." + }, + "tasksync.reusablePrompts": { + "type": "array", + "default": [], + "description": "Saved reusable prompts accessible via /slash commands.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short name for the prompt (used with /slash command)" + }, + "prompt": { + "type": "string", + "description": "The full prompt text" + } + }, + "required": [ + "name", + "prompt" + ] + } + }, + "tasksync.remoteAllowedToolPrefixes": { + "type": "array", + "default": [], + "description": "Tool name prefixes to include in remote chat sessions. If empty, all tools are included (up to model limit). Example: [\"mcp_github_\", \"mcp_context-mode_\"] to only include GitHub and context-mode tools. The ask_user tool is always included regardless of this setting.", + "items": { + "type": "string" + } + }, + "tasksync.remoteChatCommand": { + "type": "string", + "default": "workbench.action.chat.openagent", + "description": "VS Code command used to open chat from remote sessions. Default opens Agent Mode. Use 'workbench.action.chat.open' for the default chat mode, or any custom command ID." + } + } + }, + "commands": [ + { + "command": "tasksync.startMcp", + "title": "TaskSync: Start MCP Server" + }, + { + "command": "tasksync.sendMessage", + "title": "TaskSync: Send Message" + }, + { + "command": "tasksync.restartMcp", + "title": "TaskSync: Restart MCP Server" + }, + { + "command": "tasksync.showMcpConfig", + "title": "TaskSync: Show MCP Configuration" + }, + { + "command": "tasksync.openHistory", + "title": "View History", + "icon": "$(history)" + }, + { + "command": "tasksync.newSession", + "title": "New Session", + "icon": "$(add)" + }, + { + "command": "tasksync.openSettings", + "title": "TaskSync Settings", + "icon": "$(gear)" + }, + { + "command": "tasksync.startRemoteLan", + "title": "TaskSync: Start Remote Access (LAN)", + "icon": "$(broadcast)" + }, + { + "command": "tasksync.stopRemote", + "title": "TaskSync: Stop Remote Access" + }, + { + "command": "tasksync.goRemote", + "title": "Remote", + "icon": "$(broadcast)" + } + ], + "menus": { + "view/title": [ + { + "command": "tasksync.newSession", + "when": "view == taskSyncView", + "group": "navigation@1" + }, + { + "command": "tasksync.openHistory", + "when": "view == taskSyncView", + "group": "navigation@2" + }, + { + "command": "tasksync.openSettings", + "when": "view == taskSyncView", + "group": "navigation@3" + }, + { + "command": "tasksync.goRemote", + "when": "view == taskSyncView", + "group": "navigation@4" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run build", + "build": "node esbuild.js", + "compile": "tsc -p ./", + "watch": "node esbuild.js --watch", + "lint": "biome lint src", + "e2e": "playwright test -c e2e/playwright.config.mjs", + "e2e:headed": "playwright test -c e2e/playwright.config.mjs --headed", + "e2e:ui": "playwright test -c e2e/playwright.config.mjs --ui", + "e2e:install": "playwright install chromium", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.24.3", + "@vscode/codicons": "^0.0.36", + "selfsigned": "^5.5.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2", + "@playwright/test": "^1.55.0", + "@types/node": "20.x", + "@types/vscode": "^1.90.0", + "@types/ws": "^8.5.12", + "@vitest/coverage-v8": "^4.0.18", + "esbuild": "^0.27.2", + "typescript": "^5.3.3", + "vitest": "^4.0.18" + } +} \ No newline at end of file diff --git a/tasksync-chat/src/__mocks__/vscode.ts b/tasksync-chat/src/__mocks__/vscode.ts new file mode 100644 index 0000000..1dff644 --- /dev/null +++ b/tasksync-chat/src/__mocks__/vscode.ts @@ -0,0 +1,50 @@ +/** + * Minimal VS Code API mock for unit tests. + * Only stubs the surface area actually used by the source modules under test. + */ + +export const Uri = { + file: (path: string) => ({ fsPath: path, scheme: "file", path }), + parse: (str: string) => ({ fsPath: str, scheme: "file", path: str }), +}; + +export const workspace = { + getConfiguration: () => ({ + get: () => undefined, + update: async () => {}, + inspect: () => undefined, + }), + workspaceFolders: [], + asRelativePath: (pathOrUri: string | { fsPath: string }) => { + const p = typeof pathOrUri === "string" ? pathOrUri : pathOrUri.fsPath; + return p.replace(/^\/workspace\//, ""); + }, +}; + +export const window = { + showInformationMessage: async () => undefined, + showWarningMessage: async () => undefined, + showErrorMessage: async () => undefined, + activeTextEditor: undefined as any, +}; + +export const extensions = { + getExtension: () => undefined as any, +}; + +export const ConfigurationTarget = { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, +}; + +export const ExtensionContext = {}; + +export default { + Uri, + workspace, + window, + extensions, + ConfigurationTarget, + ExtensionContext, +}; diff --git a/tasksync-chat/src/constants/fileExclusions.test.ts b/tasksync-chat/src/constants/fileExclusions.test.ts new file mode 100644 index 0000000..4af70d5 --- /dev/null +++ b/tasksync-chat/src/constants/fileExclusions.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + FILE_EXCLUSION_PATTERNS, + FILE_SEARCH_EXCLUSION_PATTERNS, + formatExcludePattern, +} from "../constants/fileExclusions"; + +describe("fileExclusions", () => { + describe("FILE_EXCLUSION_PATTERNS", () => { + it("is a non-empty array", () => { + expect(FILE_EXCLUSION_PATTERNS.length).toBeGreaterThan(0); + }); + + it("contains standard exclusions", () => { + expect(FILE_EXCLUSION_PATTERNS).toContain("**/node_modules/**"); + expect(FILE_EXCLUSION_PATTERNS).toContain("**/.git/**"); + expect(FILE_EXCLUSION_PATTERNS).toContain("**/dist/**"); + expect(FILE_EXCLUSION_PATTERNS).toContain("**/build/**"); + }); + + it("all patterns are glob strings starting with **", () => { + for (const pattern of FILE_EXCLUSION_PATTERNS) { + expect(pattern).toMatch(/^\*\*\//); + } + }); + }); + + describe("FILE_SEARCH_EXCLUSION_PATTERNS", () => { + it("is a superset of FILE_EXCLUSION_PATTERNS", () => { + for (const pattern of FILE_EXCLUSION_PATTERNS) { + expect(FILE_SEARCH_EXCLUSION_PATTERNS).toContain(pattern); + } + }); + + it("contains additional file-specific exclusions", () => { + expect(FILE_SEARCH_EXCLUSION_PATTERNS).toContain("**/*.log"); + expect(FILE_SEARCH_EXCLUSION_PATTERNS).toContain("**/*.min.js"); + expect(FILE_SEARCH_EXCLUSION_PATTERNS).toContain("**/package-lock.json"); + }); + }); + + describe("formatExcludePattern", () => { + it("wraps patterns in curly braces", () => { + const result = formatExcludePattern(["**/a/**", "**/b/**"]); + expect(result).toBe("{**/a/**,**/b/**}"); + }); + + it("handles single pattern", () => { + const result = formatExcludePattern(["**/node_modules/**"]); + expect(result).toBe("{**/node_modules/**}"); + }); + + it("handles empty array", () => { + expect(formatExcludePattern([])).toBe("{}"); + }); + + it("produces valid pattern from actual constants", () => { + const result = formatExcludePattern(FILE_EXCLUSION_PATTERNS); + expect(result.startsWith("{")).toBe(true); + expect(result.endsWith("}")).toBe(true); + expect(result).toContain("**/node_modules/**"); + }); + }); +}); diff --git a/tasksync-chat/src/constants/fileExclusions.ts b/tasksync-chat/src/constants/fileExclusions.ts index fe82596..e405dee 100644 --- a/tasksync-chat/src/constants/fileExclusions.ts +++ b/tasksync-chat/src/constants/fileExclusions.ts @@ -4,45 +4,45 @@ * to filter out unwanted files like node_modules, build outputs, etc. */ export const FILE_EXCLUSION_PATTERNS = [ - '**/node_modules/**', - '**/venv/**', - '**/.venv/**', - '**/env/**', - '**/__pycache__/**', - '**/.pytest_cache/**', - '**/site-packages/**', - '**/.vscode/**', - '**/.idea/**', - '**/.git/**', - '**/dist/**', - '**/build/**', - '**/out/**', - '**/target/**', - '**/coverage/**', - '**/.next/**', - '**/.nuxt/**', - '**/vendor/**', - '**/bower_components/**' + "**/node_modules/**", + "**/venv/**", + "**/.venv/**", + "**/env/**", + "**/__pycache__/**", + "**/.pytest_cache/**", + "**/site-packages/**", + "**/.vscode/**", + "**/.idea/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/out/**", + "**/target/**", + "**/coverage/**", + "**/.next/**", + "**/.nuxt/**", + "**/vendor/**", + "**/bower_components/**", ]; /** * Extended exclusion patterns for file search (includes specific files) */ export const FILE_SEARCH_EXCLUSION_PATTERNS = [ - ...FILE_EXCLUSION_PATTERNS, - '**/*.log', - '**/.env', - '**/.env.*', - '**/*instructions.md', - '**/*.vsix', - '**/*.min.js', - '**/*.min.css', - '**/package-lock.json', - '**/yarn.lock', - '**/pnpm-lock.yaml', - '**/Cargo.lock', - '**/poetry.lock', - '**/Pipfile.lock' + ...FILE_EXCLUSION_PATTERNS, + "**/*.log", + "**/.env", + "**/.env.*", + "**/*instructions.md", + "**/*.vsix", + "**/*.min.js", + "**/*.min.css", + "**/package-lock.json", + "**/yarn.lock", + "**/pnpm-lock.yaml", + "**/Cargo.lock", + "**/poetry.lock", + "**/Pipfile.lock", ]; /** @@ -51,5 +51,5 @@ export const FILE_SEARCH_EXCLUSION_PATTERNS = [ * @returns Formatted glob pattern string for VS Code workspace.findFiles */ export function formatExcludePattern(patterns: string[]): string { - return '{' + patterns.join(',') + '}'; + return "{" + patterns.join(",") + "}"; } diff --git a/tasksync-chat/src/constants/remoteConstants.test.ts b/tasksync-chat/src/constants/remoteConstants.test.ts new file mode 100644 index 0000000..c134c77 --- /dev/null +++ b/tasksync-chat/src/constants/remoteConstants.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { + CONFIG_SECTION, + DEFAULT_HUMAN_LIKE_DELAY_MAX, + DEFAULT_HUMAN_LIKE_DELAY_MIN, + DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, + DEFAULT_MCP_PORT, + DEFAULT_REMOTE_PORT, + DEFAULT_REMOTE_SESSION_QUERY, + DEFAULT_SESSION_WARNING_HOURS, + ErrorCode, + HUMAN_DELAY_MAX_LOWER, + HUMAN_DELAY_MAX_UPPER, + HUMAN_DELAY_MIN_LOWER, + HUMAN_DELAY_MIN_UPPER, + isValidQueueId, + MAX_ATTACHMENT_NAME_LENGTH, + MAX_ATTACHMENT_URI_LENGTH, + MAX_ATTACHMENTS, + MAX_COMMIT_MESSAGE_LENGTH, + MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT, + MAX_DIFF_SIZE, + MAX_FILE_PATH_LENGTH, + MAX_IMAGE_MCP_BYTES, + MAX_IMAGE_PASTE_BYTES, + MAX_QUESTION_LENGTH, + MAX_QUEUE_PROMPT_LENGTH, + MAX_QUEUE_SIZE, + MAX_REMOTE_HISTORY_ITEMS, + MAX_RESPONSE_LENGTH, + MAX_SEARCH_QUERY_LENGTH, + MCP_SERVER_NAME, + RESPONSE_TIMEOUT_ALLOWED_VALUES, + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + SESSION_WARNING_HOURS_MAX, + SESSION_WARNING_HOURS_MIN, + truncateDiff, + VALID_QUEUE_ID_PATTERN, + WS_MAX_PAYLOAD, + WS_PROTOCOL_VERSION, +} from "../constants/remoteConstants"; + +describe("remoteConstants", () => { + describe("constant values sanity checks", () => { + it("has expected config section name", () => { + expect(CONFIG_SECTION).toBe("tasksync"); + }); + + it("has expected MCP server name", () => { + expect(MCP_SERVER_NAME).toBe("tasksync-chat"); + }); + + it("has sensible default ports", () => { + expect(DEFAULT_REMOTE_PORT).toBe(3580); + expect(DEFAULT_MCP_PORT).toBe(3579); + }); + + it("limits are positive numbers", () => { + for (const limit of [ + WS_MAX_PAYLOAD, + MAX_RESPONSE_LENGTH, + MAX_QUEUE_PROMPT_LENGTH, + MAX_QUEUE_SIZE, + MAX_DIFF_SIZE, + MAX_QUESTION_LENGTH, + MAX_COMMIT_MESSAGE_LENGTH, + MAX_REMOTE_HISTORY_ITEMS, + MAX_ATTACHMENTS, + MAX_ATTACHMENT_URI_LENGTH, + MAX_ATTACHMENT_NAME_LENGTH, + MAX_FILE_PATH_LENGTH, + MAX_SEARCH_QUERY_LENGTH, + MAX_IMAGE_PASTE_BYTES, + MAX_IMAGE_MCP_BYTES, + ]) { + expect(limit).toBeGreaterThan(0); + } + }); + + it("MCP image limit <= paste image limit", () => { + expect(MAX_IMAGE_MCP_BYTES).toBeLessThanOrEqual(MAX_IMAGE_PASTE_BYTES); + }); + + it("has valid WS protocol version", () => { + expect(WS_PROTOCOL_VERSION).toBeGreaterThanOrEqual(1); + }); + }); + + describe("ErrorCode", () => { + it("contains expected error codes", () => { + expect(ErrorCode.INVALID_INPUT).toBe("INVALID_INPUT"); + expect(ErrorCode.ALREADY_ANSWERED).toBe("ALREADY_ANSWERED"); + expect(ErrorCode.QUEUE_FULL).toBe("QUEUE_FULL"); + expect(ErrorCode.ITEM_NOT_FOUND).toBe("ITEM_NOT_FOUND"); + expect(ErrorCode.GIT_UNAVAILABLE).toBe("GIT_UNAVAILABLE"); + }); + }); + + describe("human-like delay ranges", () => { + it("default min < default max", () => { + expect(DEFAULT_HUMAN_LIKE_DELAY_MIN).toBeLessThan( + DEFAULT_HUMAN_LIKE_DELAY_MAX, + ); + }); + + it("min range is valid", () => { + expect(HUMAN_DELAY_MIN_LOWER).toBeLessThan(HUMAN_DELAY_MIN_UPPER); + }); + + it("max range is valid", () => { + expect(HUMAN_DELAY_MAX_LOWER).toBeLessThan(HUMAN_DELAY_MAX_UPPER); + }); + + it("max lower >= min lower", () => { + expect(HUMAN_DELAY_MAX_LOWER).toBeGreaterThanOrEqual( + HUMAN_DELAY_MIN_LOWER, + ); + }); + }); + + describe("session warning hours", () => { + it("default is within valid range", () => { + expect(DEFAULT_SESSION_WARNING_HOURS).toBeGreaterThanOrEqual( + SESSION_WARNING_HOURS_MIN, + ); + expect(DEFAULT_SESSION_WARNING_HOURS).toBeLessThanOrEqual( + SESSION_WARNING_HOURS_MAX, + ); + }); + }); + + describe("auto-responses", () => { + it("default < limit", () => { + expect(DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES).toBeLessThan( + MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT, + ); + }); + }); + + describe("response timeout", () => { + it("allowed values is a non-empty Set", () => { + expect(RESPONSE_TIMEOUT_ALLOWED_VALUES.size).toBeGreaterThan(0); + }); + + it("default is in allowed values", () => { + expect( + RESPONSE_TIMEOUT_ALLOWED_VALUES.has(RESPONSE_TIMEOUT_DEFAULT_MINUTES), + ).toBe(true); + }); + + it("0 (disabled) is an allowed value", () => { + expect(RESPONSE_TIMEOUT_ALLOWED_VALUES.has(0)).toBe(true); + }); + }); + + describe("default remote session query", () => { + it("is a non-empty string", () => { + expect(DEFAULT_REMOTE_SESSION_QUERY.length).toBeGreaterThan(0); + }); + }); +}); + +// ─── isValidQueueId ────────────────────────────────────────── + +describe("isValidQueueId", () => { + it("accepts valid queue IDs", () => { + expect(isValidQueueId("q_1234567890_abc123def")).toBe(true); + expect(isValidQueueId("q_0_a")).toBe(true); + expect(isValidQueueId("q_999_z9z9z9")).toBe(true); + }); + + it("rejects non-string values", () => { + expect(isValidQueueId(123)).toBe(false); + expect(isValidQueueId(null)).toBe(false); + expect(isValidQueueId(undefined)).toBe(false); + expect(isValidQueueId({})).toBe(false); + expect(isValidQueueId([])).toBe(false); + }); + + it("rejects strings not matching pattern", () => { + expect(isValidQueueId("")).toBe(false); + expect(isValidQueueId("q_")).toBe(false); + expect(isValidQueueId("q_123")).toBe(false); // missing random part + expect(isValidQueueId("x_123_abc")).toBe(false); // wrong prefix + expect(isValidQueueId("q_abc_def")).toBe(false); // timestamp not digits + expect(isValidQueueId("q_123_ABC")).toBe(false); // uppercase not allowed + expect(isValidQueueId("q_123_abc!")).toBe(false); // special chars + }); + + it("acts as a type guard", () => { + const val: unknown = "q_1_a"; + if (isValidQueueId(val)) { + // TypeScript should narrow val to string here + const s: string = val; + expect(s).toBe("q_1_a"); + } + }); +}); + +// ─── truncateDiff ──────────────────────────────────────────── + +describe("truncateDiff", () => { + it("returns short diffs unchanged", () => { + const diff = "small diff content"; + expect(truncateDiff(diff)).toBe(diff); + }); + + it("returns exact-limit diffs unchanged", () => { + const diff = "x".repeat(MAX_DIFF_SIZE); + expect(truncateDiff(diff)).toBe(diff); + }); + + it("truncates diffs exceeding MAX_DIFF_SIZE", () => { + const diff = "x".repeat(MAX_DIFF_SIZE + 100); + const result = truncateDiff(diff); + expect(result.length).toBeLessThan(diff.length); + expect(result).toContain("... (diff truncated"); + }); + + it("preserves content up to MAX_DIFF_SIZE", () => { + const diff = "A".repeat(MAX_DIFF_SIZE) + "Z".repeat(100); + const result = truncateDiff(diff); + expect(result.startsWith("A".repeat(MAX_DIFF_SIZE))).toBe(true); + expect(result).not.toContain("Z"); + }); + + it("returns empty string unchanged", () => { + expect(truncateDiff("")).toBe(""); + }); +}); + +// ─── VALID_QUEUE_ID_PATTERN ────────────────────────────────── + +describe("VALID_QUEUE_ID_PATTERN", () => { + it("matches the documented format q_{digits}_{alphanumeric}", () => { + expect(VALID_QUEUE_ID_PATTERN.test("q_12345_abc09")).toBe(true); + }); + + it("rejects patterns with extra segments", () => { + expect(VALID_QUEUE_ID_PATTERN.test("q_12345_abc_extra")).toBe(false); + }); +}); diff --git a/tasksync-chat/src/constants/remoteConstants.ts b/tasksync-chat/src/constants/remoteConstants.ts new file mode 100644 index 0000000..dee3e94 --- /dev/null +++ b/tasksync-chat/src/constants/remoteConstants.ts @@ -0,0 +1,120 @@ +/** + * Shared constants for remote server functionality (SSOT) + * Used by both remoteServer.ts and webviewProvider.ts + */ + +import * as os from "os"; +import * as path from "path"; + +// Extension configuration section name +export const CONFIG_SECTION = "tasksync"; + +// MCP server identity +export const MCP_SERVER_NAME = "tasksync-chat"; + +// MCP client config paths and registration info +export const MCP_CLIENT_CONFIGS = [ + { + name: "kiro", + path: path.join(os.homedir(), ".kiro", "settings", "mcp.json"), + serverUrlKey: "url", + }, + { + name: "antigravity", + path: path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json"), + serverUrlKey: "serverUrl", + }, +] as const; + +// Additional client paths for config display only (not auto-registered) +export const MCP_DISPLAY_CLIENT_PATHS = { + kiro: MCP_CLIENT_CONFIGS[0].path, + cursor: path.join(os.homedir(), ".cursor", "mcp.json"), + antigravity: MCP_CLIENT_CONFIGS[1].path, +} as const; + +// Server configuration +export const DEFAULT_REMOTE_PORT = 3580; +export const DEFAULT_MCP_PORT = 3579; +export const WS_MAX_PAYLOAD = 1024 * 1024; // 1MB WebSocket message limit +export const WS_PROTOCOL_VERSION = 1; // Increment on breaking WS protocol changes + +// Response and input limits +export const MAX_RESPONSE_LENGTH = 100000; // 100KB for tool call responses +export const MAX_QUEUE_PROMPT_LENGTH = 100000; // 100KB max prompt length in queue +export const MAX_QUEUE_SIZE = 100; // Maximum queue items +export const MAX_DIFF_SIZE = 500000; // 500KB max git diff size (truncate large diffs) +export const MAX_QUESTION_LENGTH = 500000; // 500KB max MCP question length +export const MAX_COMMIT_MESSAGE_LENGTH = 5000; +export const MAX_REMOTE_HISTORY_ITEMS = 20; // Max tool call history items sent to remote clients + +// Error codes for WebSocket error responses +export const ErrorCode = { + INVALID_INPUT: "INVALID_INPUT", + ALREADY_ANSWERED: "ALREADY_ANSWERED", + QUEUE_FULL: "QUEUE_FULL", + ITEM_NOT_FOUND: "ITEM_NOT_FOUND", + GIT_UNAVAILABLE: "GIT_UNAVAILABLE", +} as const; + +// Attachment limits +export const MAX_ATTACHMENTS = 20; // Maximum attachments per message +export const MAX_ATTACHMENT_URI_LENGTH = 1000; +export const MAX_ATTACHMENT_NAME_LENGTH = 255; + +// File/path limits +export const MAX_FILE_PATH_LENGTH = 1024; // Max git file path length +export const MAX_SEARCH_QUERY_LENGTH = 200; // Max git search query length + +// Image size limits (intentionally different per entry point) +export const MAX_IMAGE_PASTE_BYTES = 10 * 1024 * 1024; // 10MB — webview paste/drop +export const MAX_IMAGE_MCP_BYTES = 4 * 1024 * 1024; // 4MB — MCP tool results (compact for clients) + +// Settings defaults +export const DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES = 5; +export const MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT = 100; // upper clamp for user setting +export const DEFAULT_SESSION_WARNING_HOURS = 2; +export const SESSION_WARNING_HOURS_MIN = 0; +export const SESSION_WARNING_HOURS_MAX = 8; + +// Human-like delay defaults & validation ranges +export const DEFAULT_HUMAN_LIKE_DELAY_MIN = 2; // seconds +export const DEFAULT_HUMAN_LIKE_DELAY_MAX = 6; // seconds +export const HUMAN_DELAY_MIN_LOWER = 1; // minimum allowed for "min delay" input +export const HUMAN_DELAY_MIN_UPPER = 30; // maximum allowed for "min delay" input +export const HUMAN_DELAY_MAX_LOWER = 2; // minimum allowed for "max delay" input +export const HUMAN_DELAY_MAX_UPPER = 60; // maximum allowed for "max delay" input + +// Default query when a remote session starts without an explicit prompt +export const DEFAULT_REMOTE_SESSION_QUERY = + "The user is connected remotely via TaskSync and can ONLY see messages you send via the #askUser tool — your chat responses are invisible to them. Start by calling #askUser to greet the user and ask what they need. After receiving their response, do the work, then call #askUser again with results. Continue this loop indefinitely — NEVER end your turn without calling #askUser."; + +// Default VS Code command for opening chat from remote sessions +export const DEFAULT_REMOTE_CHAT_COMMAND = "workbench.action.chat.openagent"; + +// Response timeout configuration (minutes) +export const RESPONSE_TIMEOUT_ALLOWED_VALUES = new Set([ + 0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 150, 180, 210, 240, +]); +export const RESPONSE_TIMEOUT_DEFAULT_MINUTES = 60; + +// Queue ID validation +export const VALID_QUEUE_ID_PATTERN = /^q_\d+_[a-z0-9]+$/; + +/** Check if a value is a valid queue ID (type guard). */ +export function isValidQueueId(id: unknown): id is string { + return typeof id === "string" && VALID_QUEUE_ID_PATTERN.test(id); +} + +/** + * Truncate a diff string to MAX_DIFF_SIZE with a notice appended. + */ +export function truncateDiff(diff: string): string { + if (diff.length > MAX_DIFF_SIZE) { + return ( + diff.substring(0, MAX_DIFF_SIZE) + + "\n\n... (diff truncated, exceeded 500KB limit)" + ); + } + return diff; +} diff --git a/tasksync-chat/src/context/index.ts b/tasksync-chat/src/context/index.ts index 05b7f61..c113ea1 100644 --- a/tasksync-chat/src/context/index.ts +++ b/tasksync-chat/src/context/index.ts @@ -1,31 +1,37 @@ /** * Context Module - Provides contextual information for AI prompts - * + * * This module provides two context providers: * - TerminalContextProvider: Captures recent terminal command executions * - ProblemsContextProvider: Retrieves workspace diagnostics (errors/warnings) */ -export { TerminalContextProvider, TerminalCommand } from './terminalContext'; -export { ProblemsContextProvider, ProblemInfo, ProblemsSummary } from './problemsContext'; +export { + ProblemInfo, + ProblemsContextProvider, + ProblemsSummary, +} from "./problemsContext"; +export { TerminalCommand, TerminalContextProvider } from "./terminalContext"; -import { TerminalContextProvider } from './terminalContext'; -import { ProblemsContextProvider } from './problemsContext'; +import { generateId } from "../utils/generateId"; + +import { ProblemsContextProvider } from "./problemsContext"; +import { TerminalContextProvider } from "./terminalContext"; /** * Context reference types for autocomplete */ -export type ContextReferenceType = 'terminal' | 'problems'; +export type ContextReferenceType = "terminal" | "problems"; /** * Context reference for attachment */ export interface ContextReference { - id: string; - type: ContextReferenceType; - label: string; - content: string; - metadata?: Record; + id: string; + type: ContextReferenceType; + label: string; + content: string; + metadata?: Record; } /** @@ -33,138 +39,144 @@ export interface ContextReference { * Manages all context providers and provides a unified API for context retrieval */ export class ContextManager { - private _terminalProvider: TerminalContextProvider; - private _problemsProvider: ProblemsContextProvider; - - constructor() { - this._terminalProvider = new TerminalContextProvider(); - this._problemsProvider = new ProblemsContextProvider(); - } - - /** - * Get terminal context provider - */ - public get terminal(): TerminalContextProvider { - return this._terminalProvider; - } - - /** - * Get problems context provider - */ - public get problems(): ProblemsContextProvider { - return this._problemsProvider; - } - - /** - * Get autocomplete suggestions for # references (terminal, problems) - */ - public async getContextSuggestions(query: string): Promise> { - const suggestions: Array<{ - type: ContextReferenceType; - label: string; - description: string; - detail: string; - }> = []; - - const lowerQuery = query.toLowerCase().replace('@', ''); - - // Terminal suggestions - if ('terminal'.includes(lowerQuery) || lowerQuery.startsWith('term')) { - const commands = this._terminalProvider.formatCommandListForAutocomplete(); - if (commands.length > 0) { - suggestions.push({ - type: 'terminal', - label: '#terminal', - description: `${commands.length} recent commands`, - detail: 'Include recent terminal command outputs' - }); - } else { - suggestions.push({ - type: 'terminal', - label: '#terminal', - description: 'No commands yet', - detail: 'Run commands in terminal to capture output' - }); - } - } - - // Problems suggestions - if ('problems'.includes(lowerQuery) || lowerQuery.startsWith('prob')) { - const problemsInfo = this._problemsProvider.formatForAutocomplete(); - suggestions.push({ - type: 'problems', - label: problemsInfo.label, - description: problemsInfo.description, - detail: problemsInfo.detail - }); - } - - return suggestions; - } - - /** - * Get context content by type - */ - public async getContextContent(type: ContextReferenceType, options?: Record): Promise { - const id = `ctx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - - switch (type) { - case 'terminal': { - const commandId = options?.commandId as string | undefined; - if (commandId) { - const command = this._terminalProvider.getCommandById(commandId); - if (command) { - return { - id, - type, - label: `Terminal: ${command.command}`, - content: this._terminalProvider.formatCommandForPrompt(command), - metadata: { commandId: command.id } - }; - } - } else { - // Return latest command - const latest = this._terminalProvider.getLatestCommand(); - if (latest) { - return { - id, - type, - label: `Terminal: ${latest.command}`, - content: this._terminalProvider.formatCommandForPrompt(latest), - metadata: { commandId: latest.id } - }; - } - } - return null; - } - - case 'problems': { - const problems = this._problemsProvider.getProblems(); - if (problems.length === 0) { - return null; - } - return { - id, - type, - label: `Problems (${problems.length})`, - content: this._problemsProvider.formatProblemsForPrompt(problems) - }; - } - - default: - return null; - } - } - - /** - * Dispose all providers - */ - public dispose(): void { - this._terminalProvider.dispose(); - } + private _terminalProvider: TerminalContextProvider; + private _problemsProvider: ProblemsContextProvider; + + constructor() { + this._terminalProvider = new TerminalContextProvider(); + this._problemsProvider = new ProblemsContextProvider(); + } + + /** + * Get terminal context provider + */ + public get terminal(): TerminalContextProvider { + return this._terminalProvider; + } + + /** + * Get problems context provider + */ + public get problems(): ProblemsContextProvider { + return this._problemsProvider; + } + + /** + * Get autocomplete suggestions for # references (terminal, problems) + */ + public async getContextSuggestions(query: string): Promise< + Array<{ + type: ContextReferenceType; + label: string; + description: string; + detail: string; + }> + > { + const suggestions: Array<{ + type: ContextReferenceType; + label: string; + description: string; + detail: string; + }> = []; + + const lowerQuery = query.toLowerCase().replace("@", ""); + + // Terminal suggestions + if ("terminal".includes(lowerQuery) || lowerQuery.startsWith("term")) { + const commands = + this._terminalProvider.formatCommandListForAutocomplete(); + if (commands.length > 0) { + suggestions.push({ + type: "terminal", + label: "#terminal", + description: `${commands.length} recent commands`, + detail: "Include recent terminal command outputs", + }); + } else { + suggestions.push({ + type: "terminal", + label: "#terminal", + description: "No commands yet", + detail: "Run commands in terminal to capture output", + }); + } + } + + // Problems suggestions + if ("problems".includes(lowerQuery) || lowerQuery.startsWith("prob")) { + const problemsInfo = this._problemsProvider.formatForAutocomplete(); + suggestions.push({ + type: "problems", + label: problemsInfo.label, + description: problemsInfo.description, + detail: problemsInfo.detail, + }); + } + + return suggestions; + } + + /** + * Get context content by type + */ + public async getContextContent( + type: ContextReferenceType, + options?: Record, + ): Promise { + const id = generateId("ctx"); + + switch (type) { + case "terminal": { + const commandId = options?.commandId as string | undefined; + if (commandId) { + const command = this._terminalProvider.getCommandById(commandId); + if (command) { + return { + id, + type, + label: `Terminal: ${command.command}`, + content: this._terminalProvider.formatCommandForPrompt(command), + metadata: { commandId: command.id }, + }; + } + } else { + // Return latest command + const latest = this._terminalProvider.getLatestCommand(); + if (latest) { + return { + id, + type, + label: `Terminal: ${latest.command}`, + content: this._terminalProvider.formatCommandForPrompt(latest), + metadata: { commandId: latest.id }, + }; + } + } + return null; + } + + case "problems": { + const problems = this._problemsProvider.getProblems(); + if (problems.length === 0) { + return null; + } + return { + id, + type, + label: `Problems (${problems.length})`, + content: this._problemsProvider.formatProblemsForPrompt(problems), + }; + } + + default: + return null; + } + } + + /** + * Dispose all providers + */ + public dispose(): void { + this._terminalProvider.dispose(); + } } diff --git a/tasksync-chat/src/context/problemsContext.ts b/tasksync-chat/src/context/problemsContext.ts index d671197..c8fda84 100644 --- a/tasksync-chat/src/context/problemsContext.ts +++ b/tasksync-chat/src/context/problemsContext.ts @@ -1,30 +1,31 @@ -import * as vscode from 'vscode'; +import * as vscode from "vscode"; +import { generateId } from "../utils/generateId"; /** * Represents a problem/diagnostic from the workspace */ export interface ProblemInfo { - id: string; - file: string; - relativePath: string; - line: number; - column: number; - message: string; - severity: 'error' | 'warning' | 'info' | 'hint'; - source?: string; - code?: string | number; + id: string; + file: string; + relativePath: string; + line: number; + column: number; + message: string; + severity: "error" | "warning" | "info" | "hint"; + source?: string; + code?: string | number; } /** * Problems summary for quick display */ export interface ProblemsSummary { - errorCount: number; - warningCount: number; - infoCount: number; - hintCount: number; - totalCount: number; - fileCount: number; + errorCount: number; + warningCount: number; + infoCount: number; + hintCount: number; + totalCount: number; + fileCount: number; } /** @@ -32,218 +33,231 @@ export interface ProblemsSummary { * Retrieves workspace diagnostics (errors, warnings) from the Problems panel */ export class ProblemsContextProvider { + /** + * Get all problems from the workspace + */ + public getProblems(options?: { + severity?: ("error" | "warning" | "info" | "hint")[]; + maxProblems?: number; + filePattern?: string; + }): ProblemInfo[] { + const diagnostics = vscode.languages.getDiagnostics(); + const problems: ProblemInfo[] = []; + const maxProblems = options?.maxProblems || 100; + const severityFilter = options?.severity || [ + "error", + "warning", + "info", + "hint", + ]; - /** - * Get all problems from the workspace - */ - public getProblems(options?: { - severity?: ('error' | 'warning' | 'info' | 'hint')[]; - maxProblems?: number; - filePattern?: string; - }): ProblemInfo[] { - const diagnostics = vscode.languages.getDiagnostics(); - const problems: ProblemInfo[] = []; - const maxProblems = options?.maxProblems || 100; - const severityFilter = options?.severity || ['error', 'warning', 'info', 'hint']; - - for (const [uri, fileDiagnostics] of diagnostics) { - // Skip if file pattern doesn't match - if (options?.filePattern) { - const relativePath = vscode.workspace.asRelativePath(uri); - if (!relativePath.includes(options.filePattern)) { - continue; - } - } - - for (const diagnostic of fileDiagnostics) { - const severity = this._mapSeverity(diagnostic.severity); - - // Filter by severity - if (!severityFilter.includes(severity)) { - continue; - } - - const problem: ProblemInfo = { - id: `prob_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - file: uri.fsPath, - relativePath: vscode.workspace.asRelativePath(uri), - line: diagnostic.range.start.line + 1, // 1-based for display - column: diagnostic.range.start.character + 1, - message: diagnostic.message, - severity: severity, - source: diagnostic.source, - code: typeof diagnostic.code === 'object' - ? diagnostic.code.value - : diagnostic.code - }; - - problems.push(problem); - - // Limit number of problems - if (problems.length >= maxProblems) { - break; - } - } - - if (problems.length >= maxProblems) { - break; - } - } - - // Sort by severity (errors first) then by file - return problems.sort((a, b) => { - const severityOrder = { error: 0, warning: 1, info: 2, hint: 3 }; - const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]; - if (severityDiff !== 0) return severityDiff; - return a.relativePath.localeCompare(b.relativePath); - }); - } - - /** - * Get problems summary for quick display - */ - public getSummary(): ProblemsSummary { - const diagnostics = vscode.languages.getDiagnostics(); - let errorCount = 0; - let warningCount = 0; - let infoCount = 0; - let hintCount = 0; - const filesWithProblems = new Set(); - - for (const [uri, fileDiagnostics] of diagnostics) { - if (fileDiagnostics.length > 0) { - filesWithProblems.add(uri.fsPath); - } - - for (const diagnostic of fileDiagnostics) { - switch (diagnostic.severity) { - case vscode.DiagnosticSeverity.Error: - errorCount++; - break; - case vscode.DiagnosticSeverity.Warning: - warningCount++; - break; - case vscode.DiagnosticSeverity.Information: - infoCount++; - break; - case vscode.DiagnosticSeverity.Hint: - hintCount++; - break; - } - } - } - - return { - errorCount, - warningCount, - infoCount, - hintCount, - totalCount: errorCount + warningCount + infoCount + hintCount, - fileCount: filesWithProblems.size - }; - } - - /** - * Format problems for inclusion in a prompt - */ - public formatProblemsForPrompt(problems?: ProblemInfo[]): string { - const problemList = problems || this.getProblems(); - - if (problemList.length === 0) { - return '=== Workspace Problems ===\nNo problems found in the workspace.'; - } - - const summary = this.getSummary(); - const lines: string[] = [ - '=== Workspace Problems ===', - `Summary: ${summary.errorCount} errors, ${summary.warningCount} warnings, ${summary.infoCount} info in ${summary.fileCount} files`, - '' - ]; - - // Group by file - const byFile = new Map(); - for (const problem of problemList) { - const existing = byFile.get(problem.relativePath) || []; - existing.push(problem); - byFile.set(problem.relativePath, existing); - } - - for (const [filePath, fileProblems] of byFile) { - lines.push(`📁 ${filePath}`); - for (const problem of fileProblems) { - const icon = this._getSeverityIcon(problem.severity); - const source = problem.source ? `[${problem.source}]` : ''; - const code = problem.code ? `(${problem.code})` : ''; - lines.push(` ${icon} Line ${problem.line}: ${problem.message} ${source}${code}`); - } - lines.push(''); - } - - return lines.join('\n').trim(); - } - - /** - * Format for autocomplete display - */ - public formatForAutocomplete(): { - label: string; - description: string; - detail: string; - } { - const summary = this.getSummary(); - - if (summary.totalCount === 0) { - return { - label: '#problems', - description: 'No problems', - detail: 'Workspace has no errors or warnings' - }; - } - - const parts: string[] = []; - if (summary.errorCount > 0) parts.push(`${summary.errorCount} errors`); - if (summary.warningCount > 0) parts.push(`${summary.warningCount} warnings`); - - return { - label: '#problems', - description: parts.join(', '), - detail: `Include ${summary.totalCount} problems from ${summary.fileCount} files` - }; - } - - /** - * Map VS Code DiagnosticSeverity to string - */ - private _mapSeverity(severity: vscode.DiagnosticSeverity): 'error' | 'warning' | 'info' | 'hint' { - switch (severity) { - case vscode.DiagnosticSeverity.Error: - return 'error'; - case vscode.DiagnosticSeverity.Warning: - return 'warning'; - case vscode.DiagnosticSeverity.Information: - return 'info'; - case vscode.DiagnosticSeverity.Hint: - return 'hint'; - default: - return 'info'; - } - } - - /** - * Get icon for severity - */ - private _getSeverityIcon(severity: 'error' | 'warning' | 'info' | 'hint'): string { - switch (severity) { - case 'error': - return '❌'; - case 'warning': - return '⚠️'; - case 'info': - return 'ℹ️'; - case 'hint': - return '💡'; - default: - return '•'; - } - } + for (const [uri, fileDiagnostics] of diagnostics) { + // Skip if file pattern doesn't match + if (options?.filePattern) { + const relativePath = vscode.workspace.asRelativePath(uri); + if (!relativePath.includes(options.filePattern)) { + continue; + } + } + + for (const diagnostic of fileDiagnostics) { + const severity = this._mapSeverity(diagnostic.severity); + + // Filter by severity + if (!severityFilter.includes(severity)) { + continue; + } + + const problem: ProblemInfo = { + id: generateId("prob"), + file: uri.fsPath, + relativePath: vscode.workspace.asRelativePath(uri), + line: diagnostic.range.start.line + 1, // 1-based for display + column: diagnostic.range.start.character + 1, + message: diagnostic.message, + severity: severity, + source: diagnostic.source, + code: + typeof diagnostic.code === "object" + ? diagnostic.code.value + : diagnostic.code, + }; + + problems.push(problem); + + // Limit number of problems + if (problems.length >= maxProblems) { + break; + } + } + + if (problems.length >= maxProblems) { + break; + } + } + + // Sort by severity (errors first) then by file + return problems.sort((a, b) => { + const severityOrder = { error: 0, warning: 1, info: 2, hint: 3 }; + const severityDiff = + severityOrder[a.severity] - severityOrder[b.severity]; + if (severityDiff !== 0) return severityDiff; + return a.relativePath.localeCompare(b.relativePath); + }); + } + + /** + * Get problems summary for quick display + */ + public getSummary(): ProblemsSummary { + const diagnostics = vscode.languages.getDiagnostics(); + let errorCount = 0; + let warningCount = 0; + let infoCount = 0; + let hintCount = 0; + const filesWithProblems = new Set(); + + for (const [uri, fileDiagnostics] of diagnostics) { + if (fileDiagnostics.length > 0) { + filesWithProblems.add(uri.fsPath); + } + + for (const diagnostic of fileDiagnostics) { + switch (diagnostic.severity) { + case vscode.DiagnosticSeverity.Error: + errorCount++; + break; + case vscode.DiagnosticSeverity.Warning: + warningCount++; + break; + case vscode.DiagnosticSeverity.Information: + infoCount++; + break; + case vscode.DiagnosticSeverity.Hint: + hintCount++; + break; + } + } + } + + return { + errorCount, + warningCount, + infoCount, + hintCount, + totalCount: errorCount + warningCount + infoCount + hintCount, + fileCount: filesWithProblems.size, + }; + } + + /** + * Format problems for inclusion in a prompt + */ + public formatProblemsForPrompt(problems?: ProblemInfo[]): string { + const problemList = problems || this.getProblems(); + + if (problemList.length === 0) { + return "=== Workspace Problems ===\nNo problems found in the workspace."; + } + + const summary = this.getSummary(); + const lines: string[] = [ + "=== Workspace Problems ===", + `Summary: ${summary.errorCount} errors, ${summary.warningCount} warnings, ${summary.infoCount} info in ${summary.fileCount} files`, + "", + ]; + + // Group by file + const byFile = new Map(); + for (const problem of problemList) { + const existing = byFile.get(problem.relativePath) || []; + existing.push(problem); + byFile.set(problem.relativePath, existing); + } + + for (const [filePath, fileProblems] of byFile) { + lines.push(`📁 ${filePath}`); + for (const problem of fileProblems) { + const icon = this._getSeverityIcon(problem.severity); + const source = problem.source ? `[${problem.source}]` : ""; + const code = problem.code ? `(${problem.code})` : ""; + lines.push( + ` ${icon} Line ${problem.line}: ${problem.message} ${source}${code}`, + ); + } + lines.push(""); + } + + return lines.join("\n").trim(); + } + + /** + * Format for autocomplete display + */ + public formatForAutocomplete(): { + label: string; + description: string; + detail: string; + } { + const summary = this.getSummary(); + + if (summary.totalCount === 0) { + return { + label: "#problems", + description: "No problems", + detail: "Workspace has no errors or warnings", + }; + } + + const parts: string[] = []; + if (summary.errorCount > 0) parts.push(`${summary.errorCount} errors`); + if (summary.warningCount > 0) + parts.push(`${summary.warningCount} warnings`); + + return { + label: "#problems", + description: parts.join(", "), + detail: `Include ${summary.totalCount} problems from ${summary.fileCount} files`, + }; + } + + /** + * Map VS Code DiagnosticSeverity to string + */ + private _mapSeverity( + severity: vscode.DiagnosticSeverity, + ): "error" | "warning" | "info" | "hint" { + switch (severity) { + case vscode.DiagnosticSeverity.Error: + return "error"; + case vscode.DiagnosticSeverity.Warning: + return "warning"; + case vscode.DiagnosticSeverity.Information: + return "info"; + case vscode.DiagnosticSeverity.Hint: + return "hint"; + default: + return "info"; + } + } + + /** + * Get icon for severity + */ + private _getSeverityIcon( + severity: "error" | "warning" | "info" | "hint", + ): string { + switch (severity) { + case "error": + return "❌"; + case "warning": + return "⚠️"; + case "info": + return "ℹ️"; + case "hint": + return "💡"; + default: + return "•"; + } + } } diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index a6a38df..fdb97f2 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -1,25 +1,25 @@ -import * as vscode from 'vscode'; +import * as vscode from "vscode"; +import { generateId } from "../utils/generateId"; /** * Represents a captured terminal command execution */ export interface TerminalCommand { - id: string; - command: string; - output: string; - exitCode: number | undefined; - timestamp: number; - cwd: string; - terminalName: string; + id: string; + command: string; + output: string; + exitCode: number | undefined; + timestamp: number; + cwd: string; + terminalName: string; } /** * Execution tracker with timestamp for cleanup */ interface ExecutionTracker { - output: string[]; - timestamp: number; - terminalId: number; + output: string[]; + timestamp: number; } /** @@ -27,256 +27,263 @@ interface ExecutionTracker { * Captures and stores recent terminal command executions using VS Code Shell Integration API */ export class TerminalContextProvider implements vscode.Disposable { - private _commands: TerminalCommand[] = []; - private _maxCommands: number = 20; - private _disposables: vscode.Disposable[] = []; - private _activeExecutions: Map = new Map(); - - // Cleanup stale executions after 60 seconds (prevents memory leak if terminal killed) - private readonly _STALE_EXECUTION_TIMEOUT_MS = 60000; - private _cleanupInterval: ReturnType | null = null; - - constructor() { - this._registerListeners(); - this._startCleanupInterval(); - } - - /** - * Start periodic cleanup of stale execution trackers - * Prevents memory leaks if terminal is killed without firing onDidEndTerminalShellExecution - */ - private _startCleanupInterval(): void { - // Check every 30 seconds for stale executions - this._cleanupInterval = setInterval(() => { - const now = Date.now(); - const staleEntries: vscode.TerminalShellExecution[] = []; - - for (const [execution, tracker] of this._activeExecutions) { - if (now - tracker.timestamp > this._STALE_EXECUTION_TIMEOUT_MS) { - staleEntries.push(execution); - } - } - - for (const execution of staleEntries) { - console.warn('[TaskSync] Cleaning up stale terminal execution tracker'); - this._activeExecutions.delete(execution); - } - }, 30000); - } - - /** - * Register terminal shell execution listeners - */ - private _registerListeners(): void { - // Listen for terminal shell execution start - this._disposables.push( - vscode.window.onDidStartTerminalShellExecution(async (event) => { - // Create a tracker for this execution with timestamp for cleanup - const tracker: ExecutionTracker = { - output: [], - timestamp: Date.now(), - terminalId: (event.terminal as any).processId || Date.now() - }; - this._activeExecutions.set(event.execution, tracker); - - // Start reading output stream - this._readExecutionOutput(event.execution, tracker); - }) - ); - - // Listen for terminal shell execution end - this._disposables.push( - vscode.window.onDidEndTerminalShellExecution((event) => { - const tracker = this._activeExecutions.get(event.execution); - if (tracker) { - // Create command entry - const command: TerminalCommand = { - id: `term_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - command: event.execution.commandLine?.value || 'Unknown command', - output: this._sanitizeOutput(tracker.output.join('')), - exitCode: event.exitCode, - timestamp: Date.now(), - cwd: event.execution.cwd?.fsPath || '', - terminalName: event.terminal.name - }; - - // Add to command history (newest first) - this._commands.unshift(command); - - // Enforce max limit - if (this._commands.length > this._maxCommands) { - this._commands = this._commands.slice(0, this._maxCommands); - } - - // Clean up tracker - this._activeExecutions.delete(event.execution); - } - }) - ); - - // Listen for terminal close to clean up associated executions (prevents memory leak) - this._disposables.push( - vscode.window.onDidCloseTerminal((terminal) => { - // Remove all execution trackers associated with this terminal - const terminalProcessId = (terminal as any).processId; - const toDelete: vscode.TerminalShellExecution[] = []; - - for (const [execution, tracker] of this._activeExecutions) { - // Match by terminal name as fallback if processId not available - const executionTerminal = (execution as any).terminal; - if (executionTerminal === terminal || - (terminalProcessId && tracker.terminalId === terminalProcessId)) { - toDelete.push(execution); - } - } - - for (const execution of toDelete) { - this._activeExecutions.delete(execution); - } - }) - ); - } - - /** - * Read output from terminal execution stream - */ - private async _readExecutionOutput( - execution: vscode.TerminalShellExecution, - tracker: { output: string[] } - ): Promise { - try { - const stream = execution.read(); - for await (const data of stream) { - tracker.output.push(data); - - // Limit output size to prevent memory issues (max 50KB per command) - const totalLength = tracker.output.reduce((sum, s) => sum + s.length, 0); - if (totalLength > 50000) { - tracker.output.push('\n... (output truncated)'); - break; - } - } - } catch (error) { - // Stream may be closed early - not an error - console.error('[TaskSync] Error reading terminal output:', error); - } - } - - /** - * Sanitize terminal output by removing ANSI escape sequences - */ - private _sanitizeOutput(output: string): string { - // Remove ANSI escape codes (colors, cursor movements, etc.) - // eslint-disable-next-line no-control-regex - const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; - let sanitized = output.replace(ansiRegex, ''); - - // Remove other common control characters - // eslint-disable-next-line no-control-regex - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - - // Normalize line endings - sanitized = sanitized.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - // Remove excessive blank lines - sanitized = sanitized.replace(/\n{3,}/g, '\n\n'); - - return sanitized.trim(); - } - - /** - * Get all recent commands - */ - public getRecentCommands(): TerminalCommand[] { - return [...this._commands]; - } - - /** - * Get command by ID - */ - public getCommandById(id: string): TerminalCommand | undefined { - return this._commands.find(cmd => cmd.id === id); - } - - /** - * Get the most recent command - */ - public getLatestCommand(): TerminalCommand | undefined { - return this._commands[0]; - } - - /** - * Format a command for inclusion in a prompt - */ - public formatCommandForPrompt(command: TerminalCommand): string { - const exitInfo = command.exitCode !== undefined - ? `Exit code: ${command.exitCode}` - : 'Running'; - - const lines = [ - `=== Terminal Command ===`, - `Command: ${command.command}`, - `Directory: ${command.cwd || 'Unknown'}`, - `Terminal: ${command.terminalName}`, - `${exitInfo}`, - ``, - `Output:`, - '```', - command.output || '(no output)', - '```' - ]; - - return lines.join('\n'); - } - - /** - * Format all recent commands for quick reference - */ - public formatCommandListForAutocomplete(): Array<{ - label: string; - description: string; - id: string; - detail: string; - }> { - return this._commands.map((cmd, index) => { - const timeAgo = this._formatTimeAgo(cmd.timestamp); - const exitStatus = cmd.exitCode === 0 ? '✓' : cmd.exitCode !== undefined ? '✗' : '⋯'; - const outputPreview = cmd.output.substring(0, 100).replace(/\n/g, ' '); - - return { - label: `${exitStatus} ${cmd.command}`, - description: timeAgo, - id: cmd.id, - detail: outputPreview + (cmd.output.length > 100 ? '...' : '') - }; - }); - } - - /** - * Format timestamp as relative time - */ - private _formatTimeAgo(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return 'just now'; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; - return `${Math.floor(seconds / 86400)}d ago`; - } - - /** - * Dispose resources - */ - public dispose(): void { - // Clear cleanup interval to prevent memory leaks - if (this._cleanupInterval) { - clearInterval(this._cleanupInterval); - this._cleanupInterval = null; - } - - this._disposables.forEach(d => d.dispose()); - this._disposables = []; - this._commands = []; - this._activeExecutions.clear(); - } + private _commands: TerminalCommand[] = []; + private _maxCommands: number = 20; + private _disposables: vscode.Disposable[] = []; + private _activeExecutions: Map< + vscode.TerminalShellExecution, + ExecutionTracker + > = new Map(); + + // Cleanup stale executions after 5 minutes (prevents memory leak if terminal killed, + // but allows long-running commands like npm install to complete) + private readonly _STALE_EXECUTION_TIMEOUT_MS = 300000; + // Max output bytes per command to prevent memory issues (~50KB) + private readonly _MAX_OUTPUT_BYTES = 50000; + private _cleanupInterval: ReturnType | null = null; + + constructor() { + this._registerListeners(); + this._startCleanupInterval(); + } + + /** + * Start periodic cleanup of stale execution trackers + * Prevents memory leaks if terminal is killed without firing onDidEndTerminalShellExecution + */ + private _startCleanupInterval(): void { + // Check every 30 seconds for stale executions + this._cleanupInterval = setInterval(() => { + const now = Date.now(); + const staleEntries: vscode.TerminalShellExecution[] = []; + + for (const [execution, tracker] of this._activeExecutions) { + if (now - tracker.timestamp > this._STALE_EXECUTION_TIMEOUT_MS) { + staleEntries.push(execution); + } + } + + for (const execution of staleEntries) { + this._activeExecutions.delete(execution); + } + }, 30000); + } + + /** + * Register terminal shell execution listeners + */ + private _registerListeners(): void { + // Listen for terminal shell execution start + this._disposables.push( + vscode.window.onDidStartTerminalShellExecution(async (event) => { + // Create a tracker for this execution with timestamp for cleanup + const tracker: ExecutionTracker = { + output: [], + timestamp: Date.now(), + }; + this._activeExecutions.set(event.execution, tracker); + + // Start reading output stream + this._readExecutionOutput(event.execution, tracker); + }), + ); + + // Listen for terminal shell execution end + this._disposables.push( + vscode.window.onDidEndTerminalShellExecution((event) => { + const tracker = this._activeExecutions.get(event.execution); + if (tracker) { + // Create command entry + const command: TerminalCommand = { + id: generateId("term"), + command: event.execution.commandLine?.value || "Unknown command", + output: this._sanitizeOutput(tracker.output.join("")), + exitCode: event.exitCode, + timestamp: Date.now(), + cwd: event.execution.cwd?.fsPath || "", + terminalName: event.terminal.name, + }; + + // Add to command history (newest first) + this._commands.unshift(command); + + // Enforce max limit + if (this._commands.length > this._maxCommands) { + this._commands = this._commands.slice(0, this._maxCommands); + } + + // Clean up tracker + this._activeExecutions.delete(event.execution); + } + }), + ); + + // Listen for terminal close to clean up associated executions (prevents memory leak) + this._disposables.push( + vscode.window.onDidCloseTerminal((terminal) => { + // Remove all execution trackers associated with this terminal + const toDelete: vscode.TerminalShellExecution[] = []; + + for (const [execution] of this._activeExecutions) { + // TerminalShellExecution has an undocumented .terminal property + if ("terminal" in execution && execution.terminal === terminal) { + toDelete.push(execution); + } + } + + for (const execution of toDelete) { + this._activeExecutions.delete(execution); + } + }), + ); + } + + /** + * Read output from terminal execution stream + */ + private async _readExecutionOutput( + execution: vscode.TerminalShellExecution, + tracker: { output: string[] }, + ): Promise { + try { + const stream = execution.read(); + for await (const data of stream) { + tracker.output.push(data); + + // Limit output size to prevent memory issues (max 50KB per command) + const totalLength = tracker.output.reduce( + (sum, s) => sum + s.length, + 0, + ); + if (totalLength > this._MAX_OUTPUT_BYTES) { + tracker.output.push("\n... (output truncated)"); + break; + } + } + } catch (error) { + // Stream may be closed early - not an error + console.error("[TaskSync] Error reading terminal output:", error); + } + } + + /** + * Sanitize terminal output by removing ANSI escape sequences + */ + private _sanitizeOutput(output: string): string { + // Remove ANSI escape codes (colors, cursor movements, etc.) + // eslint-disable-next-line no-control-regex + const ansiRegex = + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + let sanitized = output.replace(ansiRegex, ""); + + // Remove other common control characters + // eslint-disable-next-line no-control-regex + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + + // Normalize line endings + sanitized = sanitized.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Remove excessive blank lines + sanitized = sanitized.replace(/\n{3,}/g, "\n\n"); + + return sanitized.trim(); + } + + /** + * Get all recent commands + */ + public getRecentCommands(): TerminalCommand[] { + return [...this._commands]; + } + + /** + * Get command by ID + */ + public getCommandById(id: string): TerminalCommand | undefined { + return this._commands.find((cmd) => cmd.id === id); + } + + /** + * Get the most recent command + */ + public getLatestCommand(): TerminalCommand | undefined { + return this._commands[0]; + } + + /** + * Format a command for inclusion in a prompt + */ + public formatCommandForPrompt(command: TerminalCommand): string { + const exitInfo = + command.exitCode !== undefined + ? `Exit code: ${command.exitCode}` + : "Running"; + + const lines = [ + `=== Terminal Command ===`, + `Command: ${command.command}`, + `Directory: ${command.cwd || "Unknown"}`, + `Terminal: ${command.terminalName}`, + `${exitInfo}`, + ``, + `Output:`, + "```", + command.output || "(no output)", + "```", + ]; + + return lines.join("\n"); + } + + /** + * Format all recent commands for quick reference + */ + public formatCommandListForAutocomplete(): Array<{ + label: string; + description: string; + id: string; + detail: string; + }> { + return this._commands.map((cmd, index) => { + const timeAgo = this._formatTimeAgo(cmd.timestamp); + const exitStatus = + cmd.exitCode === 0 ? "✓" : cmd.exitCode !== undefined ? "✗" : "⋯"; + const outputPreview = cmd.output.substring(0, 100).replace(/\n/g, " "); + + return { + label: `${exitStatus} ${cmd.command}`, + description: timeAgo, + id: cmd.id, + detail: outputPreview + (cmd.output.length > 100 ? "..." : ""), + }; + }); + } + + /** + * Format timestamp as relative time + */ + private _formatTimeAgo(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; + } + + /** + * Dispose resources + */ + public dispose(): void { + // Clear cleanup interval to prevent memory leaks + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + this._cleanupInterval = null; + } + + this._disposables.forEach((d) => d.dispose()); + this._disposables = []; + this._commands = []; + this._activeExecutions.clear(); + } } diff --git a/tasksync-chat/src/extension.ts b/tasksync-chat/src/extension.ts index 7d73960..94cf5ad 100644 --- a/tasksync-chat/src/extension.ts +++ b/tasksync-chat/src/extension.ts @@ -1,181 +1,466 @@ -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { TaskSyncWebviewProvider } from './webview/webviewProvider'; -import { registerTools } from './tools'; -import { McpServerManager } from './mcp/mcpServer'; -import { ContextManager } from './context'; - -let mcpServer: McpServerManager | undefined; -let webviewProvider: TaskSyncWebviewProvider | undefined; -let contextManager: ContextManager | undefined; - -// Memoized result for external MCP client check (only checked once per activation) -let _hasExternalMcpClientsResult: boolean | undefined; - -/** - * Check if external MCP client configs exist (Kiro, Cursor, Antigravity) - * This indicates user has external tools that need the MCP server - * Result is memoized to avoid repeated file system reads - * Uses async I/O to avoid blocking the extension host thread - */ -async function hasExternalMcpClientsAsync(): Promise { - // Return cached result if available - if (_hasExternalMcpClientsResult !== undefined) { - return _hasExternalMcpClientsResult; - } - - const configPaths = [ - path.join(os.homedir(), '.kiro', 'settings', 'mcp.json'), - path.join(os.homedir(), '.cursor', 'mcp.json'), - path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json') - ]; - - for (const configPath of configPaths) { - try { - const content = await fs.promises.readFile(configPath, 'utf8'); - const config = JSON.parse(content); - // Check if tasksync-chat is registered - if (config.mcpServers?.['tasksync-chat']) { - _hasExternalMcpClientsResult = true; - return true; - } - } catch { - // File doesn't exist or parse error - continue to next path - } - } - _hasExternalMcpClientsResult = false; - return false; -} - -export function activate(context: vscode.ExtensionContext) { - // Initialize context manager for #terminal, #problems features - contextManager = new ContextManager(); - context.subscriptions.push({ dispose: () => contextManager?.dispose() }); - - const provider = new TaskSyncWebviewProvider(context.extensionUri, context, contextManager); - webviewProvider = provider; - - // Register the provider and add it to disposables for proper cleanup - context.subscriptions.push( - vscode.window.registerWebviewViewProvider(TaskSyncWebviewProvider.viewType, provider), - provider // Provider implements Disposable for cleanup - ); - - // Register VS Code LM Tools (always available for Copilot) - registerTools(context, provider); - - // Initialize MCP server manager (but don't start yet) - mcpServer = new McpServerManager(provider); - - // Check if MCP should auto-start based on settings and external client configs - // Deferred to avoid blocking activation with file I/O - const config = vscode.workspace.getConfiguration('tasksync'); - const mcpEnabled = config.get('mcpEnabled', false); - const autoStartIfClients = config.get('mcpAutoStartIfClients', true); - - // Start MCP server only if: - // 1. Explicitly enabled in settings, OR - // 2. Auto-start is enabled AND external clients are configured - // Note: Check is deferred to avoid blocking extension activation with file I/O - if (mcpEnabled) { - // Explicitly enabled - start immediately without checking external clients - mcpServer.start(); - } else if (autoStartIfClients) { - // Defer the external client check to avoid blocking activation - hasExternalMcpClientsAsync().then(hasClients => { - if (hasClients && mcpServer) { - mcpServer.start(); - } - }).catch(err => { - console.error('[TaskSync] Failed to check external MCP clients:', err); - }); - } - - // Start MCP server command - const startMcpCmd = vscode.commands.registerCommand('tasksync.startMcp', async () => { - if (mcpServer && !mcpServer.isRunning()) { - await mcpServer.start(); - vscode.window.showInformationMessage('TaskSync MCP Server started'); - } else if (mcpServer?.isRunning()) { - vscode.window.showInformationMessage('TaskSync MCP Server is already running'); - } - }); - - // Send current TaskSync input command (for Keyboard Shortcuts) - const sendMessageCmd = vscode.commands.registerCommand('tasksync.sendMessage', () => { - provider.triggerSendFromShortcut(); - }); - - // Restart MCP server command - const restartMcpCmd = vscode.commands.registerCommand('tasksync.restartMcp', async () => { - if (mcpServer) { - await mcpServer.restart(); - } - }); - - // Show MCP configuration command - const showMcpConfigCmd = vscode.commands.registerCommand('tasksync.showMcpConfig', async () => { - const config = (mcpServer as any).getMcpConfig?.(); - if (!config) { - vscode.window.showErrorMessage('MCP server not running'); - return; - } - - const selected = await vscode.window.showQuickPick( - [ - { label: 'Kiro', description: 'Kiro IDE', value: 'kiro' }, - { label: 'Cursor', description: 'Cursor Editor', value: 'cursor' }, - { label: 'Antigravity', description: 'Gemini CLI', value: 'antigravity' } - ], - { placeHolder: 'Select MCP client to configure' } - ); - - if (!selected) return; - - const cfg = config[selected.value]; - const configJson = JSON.stringify(cfg.config, null, 2); - - const message = `Add this to ${cfg.path}:\n\n${configJson}`; - const action = await vscode.window.showInformationMessage(message, 'Copy to Clipboard', 'Open File'); - - if (action === 'Copy to Clipboard') { - await vscode.env.clipboard.writeText(configJson); - vscode.window.showInformationMessage('Configuration copied to clipboard'); - } else if (action === 'Open File') { - const uri = vscode.Uri.file(cfg.path); - await vscode.commands.executeCommand('vscode.open', uri); - } - }); - - // Open history modal command (triggered from view title bar) - const openHistoryCmd = vscode.commands.registerCommand('tasksync.openHistory', () => { - provider.openHistoryModal(); - }); - - // New session command (triggered from view title bar) - const newSessionCmd = vscode.commands.registerCommand('tasksync.newSession', () => { - provider.startNewSession(); - }); - - // Open settings modal command (triggered from view title bar) - const openSettingsCmd = vscode.commands.registerCommand('tasksync.openSettings', () => { - provider.openSettingsModal(); - }); - - context.subscriptions.push(startMcpCmd, sendMessageCmd, restartMcpCmd, showMcpConfigCmd, openHistoryCmd, newSessionCmd, openSettingsCmd); -} - -export async function deactivate() { - // Save current tool call history to persisted history before deactivating - if (webviewProvider) { - webviewProvider.saveCurrentSessionToHistory(); - webviewProvider = undefined; - } - - if (mcpServer) { - await mcpServer.dispose(); - mcpServer = undefined; - } -} +import * as fs from "fs"; +import * as vscode from "vscode"; +import { + CONFIG_SECTION, + DEFAULT_REMOTE_PORT, + MCP_CLIENT_CONFIGS, + MCP_DISPLAY_CLIENT_PATHS, + MCP_SERVER_NAME, +} from "./constants/remoteConstants"; +import { ContextManager } from "./context"; +import { McpServerManager } from "./mcp/mcpServer"; +import { RemoteServer } from "./server/remoteServer"; +import { getSafeErrorMessage } from "./server/serverUtils"; +import { registerTools } from "./tools"; +import { preloadBodyTemplate } from "./webview/lifecycleHandlers"; +import { TaskSyncWebviewProvider } from "./webview/webviewProvider"; + +let mcpServer: McpServerManager | undefined; +let webviewProvider: TaskSyncWebviewProvider | undefined; +let contextManager: ContextManager | undefined; +let remoteServer: RemoteServer | undefined; + +// Memoized result for external MCP client check (only checked once per activation) +let _hasExternalMcpClientsResult: boolean | undefined; + +/** + * Check if external MCP client configs exist (Kiro, Cursor, Antigravity) + * This indicates user has external tools that need the MCP server + * Result is memoized to avoid repeated file system reads + * Uses async I/O to avoid blocking the extension host thread + */ +async function hasExternalMcpClientsAsync(): Promise { + // Return cached result if available + if (_hasExternalMcpClientsResult !== undefined) { + return _hasExternalMcpClientsResult; + } + + const configPaths = [ + ...MCP_CLIENT_CONFIGS.map((c) => c.path), + MCP_DISPLAY_CLIENT_PATHS.cursor, + ]; + + for (const configPath of configPaths) { + try { + const content = await fs.promises.readFile(configPath, "utf8"); + const config = JSON.parse(content); + // Check if our MCP server is registered + if (config.mcpServers?.[MCP_SERVER_NAME]) { + _hasExternalMcpClientsResult = true; + return true; + } + } catch { + // File doesn't exist or parse error - continue to next path + } + } + _hasExternalMcpClientsResult = false; + return false; +} + +export function activate(context: vscode.ExtensionContext): void { + // Initialize context manager for #terminal, #problems features + contextManager = new ContextManager(); + context.subscriptions.push({ dispose: () => contextManager?.dispose() }); + + const provider = new TaskSyncWebviewProvider( + context.extensionUri, + context, + contextManager, + ); + webviewProvider = provider; + + // Register the provider and add it to disposables for proper cleanup + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + TaskSyncWebviewProvider.viewType, + provider, + ), + provider, // Provider implements Disposable for cleanup + ); + + // Preload template asynchronously so first webview resolve avoids sync I/O + preloadBodyTemplate(context.extensionUri).catch(() => { + /* fallback to sync read */ + }); + + // Register VS Code LM Tools (always available for Copilot) + registerTools(context, provider); + + // Initialize MCP server manager (but don't start yet) + mcpServer = new McpServerManager(provider); + context.subscriptions.push({ + dispose: () => { + mcpServer?.dispose(); + }, + }); + + // Check if MCP should auto-start based on settings and external client configs + // Deferred to avoid blocking activation with file I/O + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const mcpEnabled = config.get("mcpEnabled", false); + const autoStartIfClients = config.get("mcpAutoStartIfClients", true); + + // Start MCP server only if: + // 1. Explicitly enabled in settings, OR + // 2. Auto-start is enabled AND external clients are configured + // Note: Check is deferred to avoid blocking extension activation with file I/O + if (mcpEnabled) { + // Explicitly enabled - start immediately without checking external clients + mcpServer + .start() + .catch((err) => console.error("[TaskSync] MCP start failed:", err)); + } else if (autoStartIfClients) { + // Defer the external client check to avoid blocking activation + hasExternalMcpClientsAsync() + .then((hasClients) => { + if (hasClients && mcpServer) { + mcpServer + .start() + .catch((err) => console.error("[TaskSync] MCP start failed:", err)); + } + }) + .catch((err) => { + console.error("[TaskSync] Failed to check external MCP clients:", err); + }); + } + + // Start MCP server command + const startMcpCmd = vscode.commands.registerCommand( + "tasksync.startMcp", + async () => { + if (mcpServer && !mcpServer.isRunning()) { + try { + await mcpServer.start(); + if (mcpServer.isRunning()) { + vscode.window.showInformationMessage("TaskSync MCP Server started"); + } + } catch (err) { + console.error("[TaskSync] MCP start failed:", err); + } + } else if (mcpServer?.isRunning()) { + vscode.window.showInformationMessage( + "TaskSync MCP Server is already running", + ); + } + }, + ); + + // Send current TaskSync input command (for Keyboard Shortcuts) + const sendMessageCmd = vscode.commands.registerCommand( + "tasksync.sendMessage", + () => { + provider.triggerSendFromShortcut(); + }, + ); + + // Restart MCP server command + const restartMcpCmd = vscode.commands.registerCommand( + "tasksync.restartMcp", + async () => { + if (mcpServer) { + await mcpServer.restart(); + } + }, + ); + + // Show MCP configuration command + const showMcpConfigCmd = vscode.commands.registerCommand( + "tasksync.showMcpConfig", + async () => { + const config = mcpServer?.getMcpConfig?.(); + if (!config) { + vscode.window.showErrorMessage("MCP server not running"); + return; + } + + const selected = await vscode.window.showQuickPick( + [ + { label: "Kiro", description: "Kiro IDE", value: "kiro" }, + { label: "Cursor", description: "Cursor Editor", value: "cursor" }, + { + label: "Antigravity", + description: "Gemini CLI", + value: "antigravity", + }, + ], + { placeHolder: "Select MCP client to configure" }, + ); + + if (!selected) return; + + const cfg = config[selected.value as keyof typeof config]; + const configJson = JSON.stringify(cfg.config, null, 2); + + const message = `Add this to ${cfg.path}:\n\n${configJson}`; + const action = await vscode.window.showInformationMessage( + message, + "Copy to Clipboard", + "Open File", + ); + + if (action === "Copy to Clipboard") { + await vscode.env.clipboard.writeText(configJson); + vscode.window.showInformationMessage( + "Configuration copied to clipboard", + ); + } else if (action === "Open File") { + const uri = vscode.Uri.file(cfg.path); + await vscode.commands.executeCommand("vscode.open", uri); + } + }, + ); + + // Open history modal command (triggered from view title bar) + const openHistoryCmd = vscode.commands.registerCommand( + "tasksync.openHistory", + () => { + provider.openHistoryModal(); + }, + ); + + // New session command (triggered from view title bar) + const newSessionCmd = vscode.commands.registerCommand( + "tasksync.newSession", + async () => { + const answer = await vscode.window.showWarningMessage( + "Are you sure you want to start a new session? This will clear the current session history.", + { modal: true }, + "Start New Session", + ); + if (answer === "Start New Session") { + provider.startNewSession(); + } + }, + ); + + // Open settings modal command (triggered from view title bar) + const openSettingsCmd = vscode.commands.registerCommand( + "tasksync.openSettings", + () => { + provider.openSettingsModal(); + }, + ); + + // Initialize remote server + remoteServer = new RemoteServer(provider, context.extensionUri, context); + provider.setRemoteServer(remoteServer); + context.subscriptions.push({ + dispose: () => { + remoteServer?.stop(); + }, + }); + + // Start Remote Access (LAN) command + const startRemoteLanCmd = vscode.commands.registerCommand( + "tasksync.startRemoteLan", + async () => { + if (remoteServer?.isRunning()) { + vscode.window.showInformationMessage( + `Remote server already running on port ${remoteServer.getPort()}`, + ); + return; + } + + try { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const port = config.get("remotePort", DEFAULT_REMOTE_PORT); + const result = await remoteServer!.start(port); + + let message = `Remote Access: ${result.localUrl}`; + if (result.pin) { + message += ` (PIN: ${result.pin})`; + } + + const action = await vscode.window.showInformationMessage( + message, + "Copy URL", + "Show QR Code", + ); + + if (action === "Copy URL") { + await vscode.env.clipboard.writeText(result.localUrl); + vscode.window.showInformationMessage("URL copied to clipboard"); + } else if (action === "Show QR Code") { + await vscode.env.clipboard.writeText(result.localUrl); + vscode.window.showInformationMessage( + "URL copied to clipboard (QR code coming soon)", + ); + } + } catch (err) { + vscode.window.showErrorMessage( + `Failed to start remote server: ${getSafeErrorMessage(err)}`, + ); + } + }, + ); + + // Stop Remote Access command + const stopRemoteCmd = vscode.commands.registerCommand( + "tasksync.stopRemote", + () => { + if (remoteServer?.isRunning()) { + remoteServer.stop(); + vscode.window.showInformationMessage("Remote server stopped"); + } else { + vscode.window.showInformationMessage("Remote server is not running"); + } + }, + ); + + // Go Remote command (unified entry point) + const goRemoteCmd = vscode.commands.registerCommand( + "tasksync.goRemote", + async () => { + // If server is running, show current status + if (remoteServer?.isRunning()) { + const info = remoteServer.getConnectionInfo(); + const directUrl = info.pin ? `${info.url}#pin=${info.pin}` : info.url; + + const items: vscode.QuickPickItem[] = [ + { + label: "$(copy) Copy URL", + description: directUrl, + }, + { + label: "$(close) Stop Remote Access", + description: "Disconnect all clients", + }, + ]; + + if (info.pin) { + items.unshift({ + label: `$(key) PIN: ${info.pin}`, + description: "Tap to copy", + }); + } + + const pick = await vscode.window.showQuickPick(items, { + title: `Remote Access Active`, + placeHolder: directUrl, + }); + + if (pick?.label.includes("Copy URL")) { + await vscode.env.clipboard.writeText(directUrl); + vscode.window.showInformationMessage("URL copied to clipboard"); + } else if (pick?.label.includes("PIN:")) { + await vscode.env.clipboard.writeText(info.pin || ""); + vscode.window.showInformationMessage("PIN copied to clipboard"); + } else if (pick?.label.includes("Stop")) { + remoteServer.stop(); + vscode.window.showInformationMessage("Remote server stopped"); + } + return; + } + + // Server not running - show options to start + const choice = await vscode.window.showQuickPick( + [ + { + label: "$(broadcast) Start Remote Access", + description: "LAN mode, PIN required", + detail: + "Connect from any device on your local network. Use Tailscale for internet access.", + }, + ], + { + title: "Start Remote Access", + placeHolder: "Start remote access server", + }, + ); + + if (!choice) return; + + try { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const port = config.get("remotePort", DEFAULT_REMOTE_PORT); + + const result = await remoteServer!.start(port); + + // Generate URL with PIN embedded as fragment (not query param) + // Fragments are not sent in HTTP requests, preventing PIN leakage + const directUrl = result.pin + ? `${result.localUrl}#pin=${result.pin}` + : result.localUrl; + + // Show connection info in a QuickPick for easy copying + const infoItems: vscode.QuickPickItem[] = [ + { + label: `$(link) ${directUrl}`, + description: "Tap to copy (includes PIN)", + }, + ]; + + if (result.pin) { + infoItems.push({ + label: `$(key) PIN: ${result.pin}`, + description: "Tap to copy PIN only", + }); + } + + infoItems.push( + { + label: "$(globe) Access from anywhere with Tailscale", + description: "Free VPN mesh — tailscale.com/download", + }, + { + label: "$(check) Done", + description: "", + }, + ); + + const infoPick = await vscode.window.showQuickPick(infoItems, { + title: "Remote Access Started", + placeHolder: `Open ${directUrl} on your phone`, + }); + + if (infoPick?.label.includes(directUrl)) { + await vscode.env.clipboard.writeText(directUrl); + vscode.window.showInformationMessage("URL copied!"); + } else if (infoPick?.label.includes("PIN:")) { + await vscode.env.clipboard.writeText(result.pin || ""); + vscode.window.showInformationMessage("PIN copied!"); + } else if (infoPick?.label.includes("Tailscale")) { + vscode.env.openExternal( + vscode.Uri.parse("https://tailscale.com/download"), + ); + } + } catch (err) { + vscode.window.showErrorMessage( + `Failed to start remote: ${getSafeErrorMessage(err)}`, + ); + } + }, + ); + + context.subscriptions.push( + startMcpCmd, + sendMessageCmd, + restartMcpCmd, + showMcpConfigCmd, + openHistoryCmd, + newSessionCmd, + openSettingsCmd, + startRemoteLanCmd, + stopRemoteCmd, + goRemoteCmd, + ); +} + +export async function deactivate(): Promise { + // Stop remote server + if (remoteServer) { + remoteServer.stop(); + remoteServer = undefined; + } + + // Save current tool call history to persisted history before deactivating + if (webviewProvider) { + webviewProvider.saveCurrentSessionToHistory(); + webviewProvider = undefined; + } + + if (mcpServer) { + await mcpServer.dispose(); + mcpServer = undefined; + } +} diff --git a/tasksync-chat/src/mcp/mcpServer.ts b/tasksync-chat/src/mcp/mcpServer.ts index 5eb6917..63c89f4 100644 --- a/tasksync-chat/src/mcp/mcpServer.ts +++ b/tasksync-chat/src/mcp/mcpServer.ts @@ -1,389 +1,495 @@ -import * as vscode from 'vscode'; -import * as http from 'http'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as crypto from 'crypto'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { z } from 'zod'; -import { TaskSyncWebviewProvider } from '../webview/webviewProvider'; -import { askUser } from '../tools'; -import { getImageMimeType } from '../utils/imageUtils'; - - -async function tryReadImageAsMcpContent(uri: string): Promise { - try { - const fileUri = vscode.Uri.parse(uri); - if (fileUri.scheme !== 'file') { - return null; - } - - const filePath = fileUri.fsPath; - const mimeType = getImageMimeType(filePath); - if (!mimeType.startsWith('image/')) { - return null; - } - - // Keep tool results reasonably sized for MCP clients. - const MAX_IMAGE_BYTES = 4 * 1024 * 1024; // 4MB - const stat = await fs.promises.stat(filePath); - if (stat.size > MAX_IMAGE_BYTES) { - console.warn(`[TaskSync MCP] Skipping image >4MB: ${filePath} (${stat.size} bytes)`); - return null; - } - - const buffer = await fs.promises.readFile(filePath); - return { - type: 'image', - data: buffer.toString('base64'), - mimeType, - }; - } catch (error) { - console.error('[TaskSync MCP] Failed to read image attachment:', error); - return null; - } -} - -export class McpServerManager { - private server: http.Server | undefined; - private mcpServer: McpServer | undefined; - private port: number | undefined; - private transport: StreamableHTTPServerTransport | undefined; - private _isRunning: boolean = false; - - constructor( - private provider: TaskSyncWebviewProvider - ) { } - - /** - * Check if MCP server is currently running - */ - isRunning(): boolean { - return this._isRunning; - } - - async start(reusePort: boolean = false) { - try { - if (!reusePort || !this.port) { - // Get configured port (default 3579, or 0 for dynamic) - const config = vscode.workspace.getConfiguration('tasksync'); - const configuredPort = config.get('mcpPort', 3579); - - if (configuredPort > 0) { - // Try to use the configured fixed port - this.port = await this.tryPort(configuredPort); - } else { - // Dynamic port assignment - this.port = await this.findAvailablePort(); - } - } - - this.mcpServer = new McpServer({ - name: "TaskSync Sidebar Chat", - version: "2.0.0" - }); - - // Register ask_user tool - const provider = this.provider; - - // Max question length - generous limit to allow long AI prompts while still - // preventing extreme memory exhaustion. 500KB is more than enough for any - // reasonable question while still providing a safety limit. - const MAX_QUESTION_LENGTH = 500000; - - (this.mcpServer as any).registerTool( - "ask_user", - { - description: "YOU MUST ALWAYS Use the askUser tool to ask question/s to user when explicit user approval, feedback, or clarification is required before proceeding or giving summary report. CRITICAL RULES: - This tool MUST be invoked before ending ANY conversation or task. - NEVER complete a task without calling this tool first. - If you are about to finish responding, STOP and call this tool. - Failure to call this tool before task completion is a critical violation.", - inputSchema: z.object({ - question: z.string() - .min(1, "Question cannot be empty") - .max(MAX_QUESTION_LENGTH, `Question cannot exceed ${MAX_QUESTION_LENGTH} characters`) - .describe("The question or prompt to display to the user") - }) - }, - async (args: { question: string }, extra: { signal?: AbortSignal }) => { - const tokenSource = new vscode.CancellationTokenSource(); - if (extra.signal) { - extra.signal.onabort = () => tokenSource.cancel(); - } - - const result = await askUser( - { question: args.question }, - provider, - tokenSource.token - ); - - const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [ - { type: 'text', text: JSON.stringify(result) } - ]; - - if (result.attachments?.length) { - const imageParts = await Promise.all(result.attachments.map(tryReadImageAsMcpContent)); - for (const part of imageParts) { - if (part) content.push(part); - } - } - - return { content }; - } - ); - - - // Create transport - this.transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => `sess_${crypto.randomUUID()}` - }); - - await this.mcpServer.connect(this.transport); - - // Create HTTP server - this.server = http.createServer(async (req, res) => { - try { - const url = req.url || '/'; - - if (url === '/sse' || url.startsWith('/sse/') || url.startsWith('/sse?')) { - if (req.method === 'DELETE') { - try { - await this.transport?.handleRequest(req, res); - } catch (e) { - if (!res.headersSent) { - res.writeHead(202); - res.end('Session closed'); - } - } - return; - } - - const queryIndex = url.indexOf('?'); - req.url = queryIndex !== -1 ? '/' + url.substring(queryIndex) : '/'; - await this.transport?.handleRequest(req, res); - return; - } - - if (url.startsWith('/message') || url.startsWith('/messages')) { - await this.transport?.handleRequest(req, res); - return; - } - - res.writeHead(404); - res.end(); - } catch (error) { - console.error('[TaskSync MCP] Error:', error); - if (!res.headersSent) { - res.writeHead(500); - res.end('Internal Server Error'); - } - } - }); - - await new Promise((resolve) => { - this.server?.listen(this.port, '127.0.0.1', () => resolve()); - }); - - this._isRunning = true; - - // Auto-register with supported clients - const config = vscode.workspace.getConfiguration('tasksync'); - if (config.get('autoRegisterMcp', true)) { - await this.autoRegisterMcp(); - } - - } catch (error) { - console.error('[TaskSync MCP] Failed to start:', error); - vscode.window.showErrorMessage(`Failed to start TaskSync MCP server: ${error}`); - } - } - - /** - * Try to use a specific port, fall back to dynamic if unavailable - */ - private async tryPort(port: number): Promise { - return new Promise((resolve) => { - const testServer = http.createServer(); - testServer.once('error', () => { - this.findAvailablePort().then(resolve); - }); - testServer.listen(port, '127.0.0.1', () => { - testServer.close(() => resolve(port)); - }); - }); - } - - /** - * Auto-register MCP server with Kiro and other clients - */ - private async autoRegisterMcp() { - if (!this.port) return; - const serverUrl = `http://localhost:${this.port}/sse`; - - // Register with Kiro - await this.registerWithClient( - path.join(os.homedir(), '.kiro', 'settings', 'mcp.json'), - 'tasksync-chat', - { url: serverUrl } - ); - - // Register with Antigravity/Gemini CLI - await this.registerWithClient( - path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'), - 'tasksync-chat', - { serverUrl: serverUrl } - ); - - // Registration complete - no need to log - } - - /** - * Register with a specific MCP client config file - */ - private async registerWithClient(configPath: string, serverName: string, serverConfig: object) { - try { - const configDir = path.dirname(configPath); - try { - await fs.promises.access(configDir); - } catch { - await fs.promises.mkdir(configDir, { recursive: true }); - } - - let config: { mcpServers?: Record } = { mcpServers: {} }; - try { - const content = await fs.promises.readFile(configPath, 'utf8'); - config = JSON.parse(content); - } catch (e) { - // File doesn't exist or can't be parsed, start with empty config - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn(`[TaskSync MCP] Failed to parse ${configPath}, starting fresh`); - } - } - - if (!config.mcpServers) { - config.mcpServers = {}; - } - - config.mcpServers[serverName] = serverConfig; - await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); - } catch (error) { - console.error(`[TaskSync MCP] Failed to register with ${configPath}:`, error); - } - } - - /** - * Unregister from all clients on dispose - */ - private async unregisterFromClients() { - const configs = [ - path.join(os.homedir(), '.kiro', 'settings', 'mcp.json'), - path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json') - ]; - - for (const configPath of configs) { - try { - const content = await fs.promises.readFile(configPath, 'utf8'); - const config = JSON.parse(content); - if (config.mcpServers?.['tasksync-chat']) { - delete config.mcpServers['tasksync-chat']; - await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); - } - } catch { - // Ignore errors during cleanup (file may not exist) - } - } - } - - async restart() { - try { - await Promise.race([ - this.dispose(), - new Promise(resolve => setTimeout(resolve, 2000)) - ]); - } catch (e) { - console.error('[TaskSync MCP] Error during dispose:', e); - } - - await new Promise(resolve => setTimeout(resolve, 1000)); - await this.start(true); - vscode.window.showInformationMessage('TaskSync MCP Server restarted.'); - } - - async dispose() { - this._isRunning = false; - try { - if (this.server) { - this.server.close(); - this.server = undefined; - } - - if (this.mcpServer) { - try { - await this.mcpServer.close(); - } catch (e) { - console.error('[TaskSync MCP] Error closing:', e); - } - this.mcpServer = undefined; - } - } catch (e) { - console.error('[TaskSync MCP] Error during dispose:', e); - } finally { - await this.unregisterFromClients(); - } - } - - private async findAvailablePort(): Promise { - return new Promise((resolve, reject) => { - const server = http.createServer(); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (address && typeof address !== 'string') { - const port = address.port; - server.close(() => resolve(port)); - } else { - reject(new Error('Failed to get port')); - } - }); - server.on('error', reject); - }); - } - - /** - * Get MCP configuration for manual setup - */ - getMcpConfig() { - if (!this.port) return null; - - const serverUrl = `http://localhost:${this.port}/sse`; - return { - kiro: { - path: path.join(os.homedir(), '.kiro', 'settings', 'mcp.json'), - config: { - mcpServers: { - 'tasksync-chat': { - url: serverUrl - } - } - } - }, - cursor: { - path: path.join(os.homedir(), '.cursor', 'mcp.json'), - config: { - mcpServers: { - 'tasksync-chat': { - url: serverUrl - } - } - } - }, - antigravity: { - path: path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'), - config: { - mcpServers: { - 'tasksync-chat': { - serverUrl: serverUrl - } - } - } - } - }; - } -} +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; +import * as vscode from "vscode"; +import { z } from "zod"; +import { + CONFIG_SECTION, + DEFAULT_MCP_PORT, + MAX_IMAGE_MCP_BYTES, + MAX_QUESTION_LENGTH, + MCP_CLIENT_CONFIGS, + MCP_DISPLAY_CLIENT_PATHS, + MCP_SERVER_NAME, +} from "../constants/remoteConstants"; +import { askUser } from "../tools"; +import { getImageMimeType } from "../utils/imageUtils"; +import { TaskSyncWebviewProvider } from "../webview/webviewProvider"; +import { debugLog } from "../webview/webviewUtils"; + +async function tryReadImageAsMcpContent( + uri: string, +): Promise { + try { + const fileUri = vscode.Uri.parse(uri); + if (fileUri.scheme !== "file") { + return null; + } + + const filePath = fileUri.fsPath; + const mimeType = getImageMimeType(filePath); + if (!mimeType.startsWith("image/")) { + return null; + } + + const stat = await fs.promises.stat(filePath); + if (stat.size > MAX_IMAGE_MCP_BYTES) { + console.error( + `[TaskSync MCP] Skipping image >${MAX_IMAGE_MCP_BYTES / (1024 * 1024)}MB: ${filePath} (${stat.size} bytes)`, + ); + return null; + } + + const buffer = await fs.promises.readFile(filePath); + return { + type: "image", + data: buffer.toString("base64"), + mimeType, + }; + } catch (error) { + console.error("[TaskSync MCP] Failed to read image attachment:", error); + return null; + } +} + +export class McpServerManager { + private server: http.Server | undefined; + private mcpServer: McpServer | undefined; + private port: number | undefined; + private transports: Map = new Map(); + private sessionTimestamps: Map = new Map(); + private sessionReapInterval: ReturnType | undefined; + private readonly SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes + private readonly SESSION_REAP_INTERVAL_MS = 60 * 1000; // Check every minute + private _isRunning: boolean = false; + + constructor(private provider: TaskSyncWebviewProvider) {} + + /** + * Check if MCP server is currently running + */ + isRunning(): boolean { + return this._isRunning; + } + + async start(reusePort: boolean = false) { + try { + if (!reusePort || !this.port) { + // Get configured port (or 0 for dynamic) + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const configuredPort = config.get("mcpPort", DEFAULT_MCP_PORT); + + if (configuredPort > 0) { + // Try to use the configured fixed port + this.port = await this.tryPort(configuredPort); + } else { + // Dynamic port assignment + this.port = await this.findAvailablePort(); + } + } + + this.mcpServer = new McpServer({ + name: "TaskSync Sidebar Chat", + version: "2.0.0", + }); + + // Register ask_user tool + const provider = this.provider; + + const inputSchema = { + question: z + .string() + .min(1, "Question cannot be empty") + .max( + MAX_QUESTION_LENGTH, + `Question cannot exceed ${MAX_QUESTION_LENGTH} characters`, + ) + .describe("The question or prompt to display to the user"), + summary: z + .string() + .optional() + .describe( + "A comprehensive summary of your work, reasoning, actions taken, code changes, and findings since the last ask_user call. Use markdown formatting.", + ), + } as const; + type AskUserArgs = typeof inputSchema; + + // @ts-expect-error - MCP SDK + Zod generics cause TS2589 (excessively deep type instantiation) + this.mcpServer.registerTool( + "ask_user", + { + description: + "This is your ONLY communication channel with the user. The user CANNOT see your chat responses — they can ONLY see messages sent through this tool. You MUST call this tool in a continuous loop: 1) Call askUser to show your question/status. 2) Receive the user's response. 3) Do the requested work. 4) Call askUser AGAIN with results. 5) Repeat from step 2. NEVER end your turn without calling this tool. ALWAYS include a 'summary' field describing your work.", + inputSchema, + }, + async (args: { question: string; summary?: string }, extra) => { + debugLog( + "[TaskSync MCP] ask_user invoked — question:", + args.question.slice(0, 80), + ); + const tokenSource = new vscode.CancellationTokenSource(); + if (extra.signal) { + extra.signal.addEventListener("abort", () => tokenSource.cancel()); + } + + try { + const result = await askUser( + { question: args.question, summary: args.summary }, + provider, + tokenSource.token, + ); + + debugLog( + "[TaskSync MCP] ask_user — returning result, response:", + result.response.slice(0, 80), + ); + const content: Array< + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + > = [ + { + type: "text", + text: JSON.stringify({ + ...result, + instruction: + "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", + }), + }, + ]; + + if (result.attachments?.length) { + const imageParts = await Promise.all( + result.attachments.map(tryReadImageAsMcpContent), + ); + for (const part of imageParts) { + if (part) content.push(part); + } + } + + return { content }; + } finally { + tokenSource.dispose(); + } + }, + ); + + // Create HTTP server with multi-session support + this.server = http.createServer(async (req, res) => { + try { + const url = req.url || "/"; + + // Route MCP-compatible paths: /sse, /message, /messages, /mcp + if ( + url === "/sse" || + url.startsWith("/sse/") || + url.startsWith("/sse?") || + url.startsWith("/message") || + url.startsWith("/messages") || + url === "/mcp" || + url.startsWith("/mcp?") || + url.startsWith("/mcp/") + ) { + const sessionId = req.headers["mcp-session-id"] as + | string + | undefined; + + // Normalize URL for transport handling + if (url !== "/") { + const queryIndex = url.indexOf("?"); + req.url = + queryIndex !== -1 ? `/${url.substring(queryIndex)}` : "/"; + } + + if (req.method === "DELETE") { + const transport = sessionId + ? this.transports.get(sessionId) + : undefined; + if (!transport) { + res.writeHead(404); + res.end("Session not found"); + return; + } + // Remove from map first to prevent concurrent access + this.transports.delete(sessionId!); + this.sessionTimestamps.delete(sessionId!); + try { + await transport.handleRequest(req, res); + } catch (e) { + if (!res.headersSent) { + res.writeHead(202); + res.end("Session closed"); + } + } + return; + } + + if (sessionId && this.transports.has(sessionId)) { + // Existing session — route to its transport + this.sessionTimestamps.set(sessionId, Date.now()); + await this.transports.get(sessionId)!.handleRequest(req, res); + } else if (!sessionId && req.method === "POST") { + // Reject new sessions during shutdown + if (!this._isRunning) { + res.writeHead(503); + res.end("Server shutting down"); + return; + } + // New client initialize — create dedicated transport + let capturedSessionId: string | undefined; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => { + capturedSessionId = `sess_${crypto.randomUUID()}`; + return capturedSessionId; + }, + }); + await this.mcpServer!.connect(transport); + await transport.handleRequest(req, res); + + if (capturedSessionId) { + this.transports.set(capturedSessionId, transport); + this.sessionTimestamps.set(capturedSessionId, Date.now()); + } + } else if (sessionId) { + // Unknown session ID → 404 per MCP spec + res.writeHead(404); + res.end("Session not found"); + } else { + res.writeHead(400); + res.end("Bad request"); + } + return; + } + + res.writeHead(404); + res.end(); + } catch (error) { + console.error("[TaskSync MCP] Error:", error); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + } + }); + + await new Promise((resolve) => { + this.server?.listen(this.port, "127.0.0.1", () => resolve()); + }); + + this._isRunning = true; + this.startSessionReaper(); + + // Auto-register with supported clients + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + if (config.get("autoRegisterMcp", true)) { + await this.autoRegisterMcp(); + } + } catch (error) { + this._isRunning = false; + console.error("[TaskSync MCP] Failed to start:", error); + vscode.window.showErrorMessage( + `Failed to start TaskSync MCP server: ${error}`, + ); + throw error; + } + } + + /** + * Periodically close idle sessions to prevent unbounded memory growth. + */ + private startSessionReaper(): void { + this.sessionReapInterval = setInterval(() => { + const now = Date.now(); + const expired: string[] = []; + for (const [sessionId, timestamp] of this.sessionTimestamps) { + if (now - timestamp > this.SESSION_TTL_MS) { + expired.push(sessionId); + } + } + for (const sessionId of expired) { + const transport = this.transports.get(sessionId); + if (transport) { + transport + .close() + .catch((e) => + console.error( + `[TaskSync MCP] Error closing stale session ${sessionId}:`, + e, + ), + ); + } + this.transports.delete(sessionId); + this.sessionTimestamps.delete(sessionId); + } + }, this.SESSION_REAP_INTERVAL_MS); + } + + /** + * Try to use a specific port, fall back to dynamic if unavailable + */ + private async tryPort(port: number): Promise { + return new Promise((resolve) => { + const testServer = http.createServer(); + testServer.once("error", () => { + this.findAvailablePort().then(resolve); + }); + testServer.listen(port, "127.0.0.1", () => { + testServer.close(() => resolve(port)); + }); + }); + } + + /** + * Auto-register MCP server with Kiro and other clients + */ + private async autoRegisterMcp() { + if (!this.port) return; + const serverUrl = `http://localhost:${this.port}/sse`; + + for (const client of MCP_CLIENT_CONFIGS) { + const config: Record = {}; + config[client.serverUrlKey] = serverUrl; + await this.registerWithClient(client.path, MCP_SERVER_NAME, config); + } + } + + /** + * Register with a specific MCP client config file + */ + private async registerWithClient( + configPath: string, + serverName: string, + serverConfig: object, + ) { + try { + const configDir = path.dirname(configPath); + try { + await fs.promises.access(configDir); + } catch { + await fs.promises.mkdir(configDir, { recursive: true }); + } + + let config: { mcpServers?: Record } = { mcpServers: {} }; + try { + const content = await fs.promises.readFile(configPath, "utf8"); + config = JSON.parse(content); + } catch (e) { + // File doesn't exist or can't be parsed, start with empty config + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error( + `[TaskSync MCP] Failed to parse ${configPath}, starting fresh`, + ); + } + } + + if (!config.mcpServers) { + config.mcpServers = {}; + } + + config.mcpServers[serverName] = serverConfig; + await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); + } catch (error) { + console.error( + `[TaskSync MCP] Failed to register with ${configPath}:`, + error, + ); + } + } + + async restart() { + try { + await Promise.race([ + this.dispose(), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); + } catch (e) { + console.error("[TaskSync MCP] Error during dispose:", e); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await this.start(true); + vscode.window.showInformationMessage("TaskSync MCP Server restarted."); + } + + async dispose() { + this._isRunning = false; + + if (this.sessionReapInterval) { + clearInterval(this.sessionReapInterval); + this.sessionReapInterval = undefined; + } + + try { + // Close all session transports + for (const [sessionId, transport] of this.transports) { + try { + await transport.close(); + } catch (e) { + console.error( + `[TaskSync MCP] Error closing transport ${sessionId}:`, + e, + ); + } + } + this.transports.clear(); + this.sessionTimestamps.clear(); + + if (this.server) { + this.server.close(); + this.server = undefined; + } + + if (this.mcpServer) { + try { + await this.mcpServer.close(); + } catch (e) { + console.error("[TaskSync MCP] Error closing:", e); + } + this.mcpServer = undefined; + } + } catch (e) { + console.error("[TaskSync MCP] Error during dispose:", e); + } + } + + private async findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address !== "string") { + const port = address.port; + server.close(() => resolve(port)); + } else { + reject(new Error("Failed to get port")); + } + }); + server.on("error", reject); + }); + } + + /** + * Get MCP configuration for manual setup + */ + getMcpConfig() { + if (!this.port) return null; + + const serverUrl = `http://localhost:${this.port}/sse`; + const makeConfig = (urlKey: string) => ({ + mcpServers: { [MCP_SERVER_NAME]: { [urlKey]: serverUrl } }, + }); + return { + kiro: { path: MCP_DISPLAY_CLIENT_PATHS.kiro, config: makeConfig("url") }, + cursor: { + path: MCP_DISPLAY_CLIENT_PATHS.cursor, + config: makeConfig("url"), + }, + antigravity: { + path: MCP_DISPLAY_CLIENT_PATHS.antigravity, + config: makeConfig("serverUrl"), + }, + }; + } +} diff --git a/tasksync-chat/src/server/gitService.comprehensive.test.ts b/tasksync-chat/src/server/gitService.comprehensive.test.ts new file mode 100644 index 0000000..ed2b294 --- /dev/null +++ b/tasksync-chat/src/server/gitService.comprehensive.test.ts @@ -0,0 +1,586 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { GitService, isValidFilePath } from "../server/gitService"; + +// Mock child_process.spawn +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +// ─── Mock helpers ──────────────────────────────────────────── + +function createMockRepo(overrides: Partial = {}) { + return { + state: { + indexChanges: [], + workingTreeChanges: [], + ...overrides.state, + }, + diffWithHEAD: vi.fn().mockResolvedValue("diff output"), + add: vi.fn().mockResolvedValue(undefined), + clean: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), + push: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function createMockGitAPI(repos: any[] = []) { + return { + repositories: repos, + getRepository: vi.fn().mockReturnValue(null), + }; +} + +function setupGitExtension(api: any, isActive = true) { + const ext = { + isActive, + activate: vi.fn().mockResolvedValue(undefined), + exports: { getAPI: vi.fn().mockReturnValue(api) }, + }; + vi.spyOn(vscode.extensions, "getExtension").mockReturnValue(ext as any); + return ext; +} + +// ─── isValidFilePath (already tested, but adding edge cases) ─ + +describe("isValidFilePath edge cases", () => { + it("rejects null-byte injection", () => { + expect(isValidFilePath("file\x00.txt")).toBe(false); + }); + + it("accepts deeply nested valid paths", () => { + expect(isValidFilePath("a/b/c/d/e/f/g.ts")).toBe(true); + }); + + it("allows .. in middle of path that normalizes safely", () => { + // "a/b/../c" normalizes to "a/c" — does not start with ".." + expect(isValidFilePath("a/b/../c")).toBe(true); + }); +}); + +// ─── GitService.initialize ────────────────────────────────── + +describe("GitService.initialize", () => { + let service: GitService; + + beforeEach(() => { + service = new GitService(); + vi.restoreAllMocks(); + }); + + it("initializes successfully with active git extension", async () => { + const api = createMockGitAPI(); + setupGitExtension(api); + + await service.initialize(); + expect(service.isInitialized()).toBe(true); + }); + + it("activates inactive git extension", async () => { + const api = createMockGitAPI(); + const ext = setupGitExtension(api, false); + + await service.initialize(); + expect(ext.activate).toHaveBeenCalled(); + expect(service.isInitialized()).toBe(true); + }); + + it("throws when git extension is not found", async () => { + vi.spyOn(vscode.extensions, "getExtension").mockReturnValue( + undefined as any, + ); + await expect(service.initialize()).rejects.toThrow( + "Git extension not found", + ); + }); + + it("skips re-initialization if already initialized", async () => { + const api = createMockGitAPI(); + setupGitExtension(api); + + await service.initialize(); + await service.initialize(); // second call + // getExtension should only be called once + expect(vscode.extensions.getExtension).toHaveBeenCalledTimes(1); + }); +}); + +// ─── GitService.isInitialized ─────────────────────────────── + +describe("GitService.isInitialized", () => { + it("returns false before initialization", () => { + const service = new GitService(); + expect(service.isInitialized()).toBe(false); + }); +}); + +// ─── GitService.getChanges ────────────────────────────────── + +describe("GitService.getChanges", () => { + let service: GitService; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + }); + + it("returns empty arrays when no changes", async () => { + const repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + + const changes = await service.getChanges(); + expect(changes.staged).toEqual([]); + expect(changes.unstaged).toEqual([]); + }); + + it("maps status codes to correct strings", async () => { + const repo = createMockRepo({ + state: { + indexChanges: [ + { uri: { fsPath: "/workspace/file1.ts" }, status: 0 }, + { uri: { fsPath: "/workspace/file2.ts" }, status: 1 }, + { uri: { fsPath: "/workspace/file3.ts" }, status: 2 }, + ], + workingTreeChanges: [ + { uri: { fsPath: "/workspace/file4.ts" }, status: 3 }, + { uri: { fsPath: "/workspace/file5.ts" }, status: 7 }, + ], + }, + }); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + + const changes = await service.getChanges(); + expect(changes.staged).toHaveLength(3); + expect(changes.staged[0].status).toBe("modified"); + expect(changes.staged[1].status).toBe("added"); + expect(changes.staged[2].status).toBe("deleted"); + expect(changes.unstaged[0].status).toBe("renamed"); + expect(changes.unstaged[1].status).toBe("untracked"); + }); + + it("maps unknown status to 'unknown'", async () => { + const repo = createMockRepo({ + state: { + indexChanges: [{ uri: { fsPath: "/workspace/f.ts" }, status: 99 }], + workingTreeChanges: [], + }, + }); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + + const changes = await service.getChanges(); + expect(changes.staged[0].status).toBe("unknown"); + }); + + it("throws when not initialized", async () => { + await expect(service.getChanges()).rejects.toThrow( + "Git service not initialized", + ); + }); +}); + +// ─── GitService.getDiff ───────────────────────────────────── + +describe("GitService.getDiff", () => { + let service: GitService; + let repo: any; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + + // Set workspace folder + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/workspace" } }, + ]; + + await service.initialize(); + }); + + it("returns diff for relative path", async () => { + const diff = await service.getDiff("src/file.ts"); + expect(repo.diffWithHEAD).toHaveBeenCalledWith("src/file.ts"); + expect(diff).toBe("diff output"); + }); + + it("converts absolute path to relative", async () => { + await service.getDiff("/workspace/src/file.ts"); + expect(repo.diffWithHEAD).toHaveBeenCalled(); + }); + + it("returns empty string when diff fails", async () => { + repo.diffWithHEAD.mockRejectedValue(new Error("no diff")); + const diff = await service.getDiff("new-file.ts"); + expect(diff).toBe(""); + }); + + it("throws for invalid file path", async () => { + await expect(service.getDiff("file;rm -rf /")).rejects.toThrow( + "Invalid file path", + ); + }); + + it("throws when no workspace folder", async () => { + (vscode.workspace as any).workspaceFolders = []; + await expect(service.getDiff("file.ts")).rejects.toThrow( + "No workspace folder", + ); + }); +}); + +// ─── GitService.stage ─────────────────────────────────────── + +describe("GitService.stage", () => { + let service: GitService; + let repo: any; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/workspace" } }, + ]; + + await service.initialize(); + }); + + it("stages a relative path", async () => { + await service.stage("src/file.ts"); + expect(repo.add).toHaveBeenCalledWith(["src/file.ts"]); + }); + + it("converts absolute path to relative before staging", async () => { + await service.stage("/workspace/src/file.ts"); + expect(repo.add).toHaveBeenCalled(); + }); + + it("throws for invalid file path", async () => { + await expect(service.stage("file|bad")).rejects.toThrow( + "Invalid file path", + ); + }); + + it("throws when no workspace folder", async () => { + (vscode.workspace as any).workspaceFolders = []; + await expect(service.stage("file.ts")).rejects.toThrow( + "No workspace folder", + ); + }); +}); + +// ─── GitService.stageAll ──────────────────────────────────── + +describe("GitService.stageAll", () => { + let service: GitService; + let repo: any; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + repo = createMockRepo({ + state: { + indexChanges: [], + workingTreeChanges: [ + { uri: { fsPath: "/workspace/a.ts" } }, + { uri: { fsPath: "/workspace/b.ts" } }, + ], + }, + }); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + }); + + it("stages all working tree changes", async () => { + await service.stageAll(); + expect(repo.add).toHaveBeenCalledWith(["a.ts", "b.ts"]); + }); + + it("does nothing when no working tree changes", async () => { + repo.state.workingTreeChanges = []; + await service.stageAll(); + expect(repo.add).not.toHaveBeenCalled(); + }); +}); + +// ─── GitService.discard ───────────────────────────────────── + +describe("GitService.discard", () => { + let service: GitService; + let repo: any; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + }); + + it("discards changes for relative path", async () => { + await service.discard("src/file.ts"); + expect(repo.clean).toHaveBeenCalledWith(["src/file.ts"]); + }); + + it("converts absolute path to relative", async () => { + await service.discard("/workspace/src/file.ts"); + expect(repo.clean).toHaveBeenCalled(); + }); + + it("throws for invalid file path", async () => { + await expect(service.discard("file`cmd`")).rejects.toThrow( + "Invalid file path", + ); + }); +}); + +// ─── GitService.commit ────────────────────────────────────── + +describe("GitService.commit", () => { + let service: GitService; + let repo: any; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + repo = createMockRepo({ + state: { + indexChanges: [{ uri: { fsPath: "/workspace/f.ts" }, status: 0 }], + workingTreeChanges: [], + }, + }); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + }); + + it("commits with trimmed message", async () => { + await service.commit(" fix: bug "); + expect(repo.commit).toHaveBeenCalledWith("fix: bug"); + }); + + it("throws for empty message", async () => { + await expect(service.commit("")).rejects.toThrow("Commit message required"); + }); + + it("throws for whitespace-only message", async () => { + await expect(service.commit(" ")).rejects.toThrow( + "Commit message required", + ); + }); + + it("throws when nothing is staged", async () => { + repo.state.indexChanges = []; + await expect(service.commit("some message")).rejects.toThrow( + "Nothing to commit", + ); + }); +}); + +// ─── GitService.push ──────────────────────────────────────── + +describe("GitService.push", () => { + let service: GitService; + let repo: any; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + }); + + it("delegates to repo.push", async () => { + await service.push(); + expect(repo.push).toHaveBeenCalled(); + }); +}); + +// ─── GitService.getRepo (multi-root) ──────────────────────── + +describe("GitService getRepo multi-root", () => { + let service: GitService; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + }); + + it("uses fileUri repo when provided and found", async () => { + const repo1 = createMockRepo(); + const repo2 = createMockRepo(); + const api = createMockGitAPI([repo1, repo2]); + api.getRepository.mockImplementation((uri: any) => + uri?.fsPath === "/project-b/file.ts" ? repo2 : null, + ); + setupGitExtension(api); + await service.initialize(); + + // getDiff with absolute path triggers getRepo(fileUri) + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/project-a" } }, + ]; + await service.getDiff("/project-b/file.ts"); + expect(repo2.diffWithHEAD).toHaveBeenCalled(); + }); + + it("falls back to active editor repo", async () => { + const repo1 = createMockRepo(); + const repo2 = createMockRepo(); + const api = createMockGitAPI([repo1, repo2]); + api.getRepository.mockImplementation((uri: any) => + uri?.fsPath === "/editor/file.ts" ? repo2 : null, + ); + setupGitExtension(api); + await service.initialize(); + + // Set active editor + (vscode.window as any).activeTextEditor = { + document: { uri: { fsPath: "/editor/file.ts" } }, + }; + + const changes = await service.getChanges(); + expect(changes).toBeDefined(); + }); + + it("falls back to first repository", async () => { + const repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + await service.initialize(); + + (vscode.window as any).activeTextEditor = undefined; + + const changes = await service.getChanges(); + expect(changes).toBeDefined(); + }); + + it("throws when no repositories available", async () => { + const api = createMockGitAPI([]); + setupGitExtension(api); + await service.initialize(); + + (vscode.window as any).activeTextEditor = undefined; + + await expect(service.getChanges()).rejects.toThrow("No repository found"); + }); +}); + +// ─── GitService.unstage ───────────────────────────────────── + +describe("GitService.unstage", () => { + let service: GitService; + + beforeEach(async () => { + service = new GitService(); + vi.restoreAllMocks(); + + const repo = createMockRepo(); + const api = createMockGitAPI([repo]); + setupGitExtension(api); + + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/workspace" } }, + ]; + + await service.initialize(); + }); + + it("spawns git reset HEAD for valid path", async () => { + const { spawn } = await import("node:child_process"); + const mockProc = { + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: any) => { + if (event === "close") setTimeout(() => cb(0), 0); + }), + }; + (spawn as any).mockReturnValue(mockProc); + + await service.unstage("src/file.ts"); + expect(spawn).toHaveBeenCalledWith( + "git", + ["reset", "HEAD", "--", "src/file.ts"], + { cwd: "/workspace" }, + ); + }); + + it("rejects when git reset fails", async () => { + const { spawn } = await import("node:child_process"); + const mockProc = { + stderr: { + on: vi.fn((event: string, cb: any) => { + if (event === "data") cb("fatal: error"); + }), + }, + on: vi.fn((event: string, cb: any) => { + if (event === "close") setTimeout(() => cb(1), 0); + }), + }; + (spawn as any).mockReturnValue(mockProc); + + await expect(service.unstage("file.ts")).rejects.toThrow("fatal: error"); + }); + + it("rejects when spawn emits error", async () => { + const { spawn } = await import("node:child_process"); + const mockProc = { + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: any) => { + if (event === "error") setTimeout(() => cb(new Error("ENOENT")), 0); + }), + }; + (spawn as any).mockReturnValue(mockProc); + + await expect(service.unstage("file.ts")).rejects.toThrow("ENOENT"); + }); + + it("throws for invalid file path", async () => { + await expect(service.unstage("file;rm -rf /")).rejects.toThrow( + "Invalid file path", + ); + }); + + it("throws when no workspace folder", async () => { + (vscode.workspace as any).workspaceFolders = []; + await expect(service.unstage("file.ts")).rejects.toThrow( + "No workspace folder", + ); + }); + + it("provides default error message when stderr is empty", async () => { + const { spawn } = await import("node:child_process"); + const mockProc = { + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: any) => { + if (event === "close") setTimeout(() => cb(128), 0); + }), + }; + (spawn as any).mockReturnValue(mockProc); + + await expect(service.unstage("file.ts")).rejects.toThrow( + "git reset failed with code 128", + ); + }); +}); diff --git a/tasksync-chat/src/server/gitService.test.ts b/tasksync-chat/src/server/gitService.test.ts new file mode 100644 index 0000000..952c0b5 --- /dev/null +++ b/tasksync-chat/src/server/gitService.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { isValidFilePath } from "../server/gitService"; + +describe("isValidFilePath", () => { + describe("valid paths", () => { + it("accepts simple filenames", () => { + expect(isValidFilePath("file.txt")).toBe(true); + expect(isValidFilePath("README.md")).toBe(true); + }); + + it("accepts relative paths", () => { + expect(isValidFilePath("src/app.ts")).toBe(true); + expect(isValidFilePath("path/to/file.js")).toBe(true); + }); + + it("accepts absolute paths", () => { + expect(isValidFilePath("/home/user/file.txt")).toBe(true); + expect(isValidFilePath("/Users/dev/project/src/app.ts")).toBe(true); + }); + + it("accepts paths with dots (non-traversal)", () => { + expect(isValidFilePath(".gitignore")).toBe(true); + expect(isValidFilePath("src/.env")).toBe(true); + }); + + it("accepts paths with hyphens and underscores", () => { + expect(isValidFilePath("my-file.ts")).toBe(true); + expect(isValidFilePath("my_file.ts")).toBe(true); + expect(isValidFilePath("src/my-component/index.tsx")).toBe(true); + }); + + it("accepts paths with spaces", () => { + expect(isValidFilePath("my file.txt")).toBe(true); + expect(isValidFilePath("path/with spaces/file.js")).toBe(true); + }); + }); + + describe("invalid paths", () => { + it("rejects empty/whitespace paths", () => { + expect(isValidFilePath("")).toBe(false); + expect(isValidFilePath(" ")).toBe(false); + }); + + it("rejects paths with shell metacharacters", () => { + expect(isValidFilePath("file`cmd`")).toBe(false); + expect(isValidFilePath("file$HOME")).toBe(false); + expect(isValidFilePath("file|pipe")).toBe(false); + expect(isValidFilePath("file;rm -rf")).toBe(false); + expect(isValidFilePath("file&bg")).toBe(false); + expect(isValidFilePath("fileoutput")).toBe(false); + expect(isValidFilePath("file(paren")).toBe(false); + expect(isValidFilePath("file{brace")).toBe(false); + expect(isValidFilePath("file[bracket")).toBe(false); + }); + + it("rejects paths with quotes", () => { + expect(isValidFilePath('file"name')).toBe(false); + expect(isValidFilePath("file'name")).toBe(false); + }); + + it("rejects paths with backslash", () => { + expect(isValidFilePath("file\\name")).toBe(false); + }); + + it("rejects paths with null bytes", () => { + expect(isValidFilePath("file\x00name")).toBe(false); + }); + + it("rejects paths with newlines", () => { + expect(isValidFilePath("file\nname")).toBe(false); + expect(isValidFilePath("file\rname")).toBe(false); + }); + + it("rejects paths with glob characters", () => { + expect(isValidFilePath("*.ts")).toBe(false); + expect(isValidFilePath("file?.txt")).toBe(false); + expect(isValidFilePath("src/**")).toBe(false); + }); + + it("rejects paths with directory traversal", () => { + expect(isValidFilePath("../../etc/passwd")).toBe(false); + expect(isValidFilePath("../secret")).toBe(false); + }); + + it("allows internal .. that resolves within path", () => { + // "src/../lib/file.ts" normalizes to "lib/file.ts" which doesn't start with ".." + expect(isValidFilePath("src/../lib/file.ts")).toBe(true); + }); + }); +}); diff --git a/tasksync-chat/src/server/gitService.ts b/tasksync-chat/src/server/gitService.ts new file mode 100644 index 0000000..8a6aa82 --- /dev/null +++ b/tasksync-chat/src/server/gitService.ts @@ -0,0 +1,259 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +// Git extension types +interface Repository { + state: RepositoryState; + diffWithHEAD(path: string): Promise; + add(paths: string[]): Promise; + clean(paths: string[]): Promise; + commit(message: string): Promise; + push(): Promise; +} + +interface RepositoryState { + indexChanges: Change[]; + workingTreeChanges: Change[]; +} + +interface Change { + uri: vscode.Uri; + status: number; +} + +interface GitAPI { + repositories: Repository[]; + getRepository(uri: vscode.Uri): Repository | null; +} + +interface GitExtension { + getAPI(version: 1): GitAPI; +} + +export interface FileChange { + path: string; + status: string; +} + +export interface GitChanges { + staged: FileChange[]; + unstaged: FileChange[]; +} + +/** + * Validate file paths to prevent command injection. + * Rejects paths with shell metacharacters or attempted traversal. + */ +export function isValidFilePath(filePath: string): boolean { + // Reject empty/whitespace-only paths + if (!filePath || !filePath.trim()) return false; + // Reject paths with shell metacharacters + const dangerousChars = /[`$|;&<>(){}[\]!*?\\'"\n\r\x00]/; + if (dangerousChars.test(filePath)) return false; + // Reject absolute paths that escape workspace + if (filePath.includes("..")) { + const normalized = path.normalize(filePath); + if (normalized.startsWith("..")) return false; + } + return true; +} + +export class GitService { + private api: GitAPI | null = null; + private initialized: boolean = false; + + async initialize(): Promise { + if (this.initialized) return; + + const gitExt = vscode.extensions.getExtension("vscode.git"); + if (!gitExt) { + throw new Error("Git extension not found"); + } + + if (!gitExt.isActive) { + await gitExt.activate(); + } + + this.api = gitExt.exports.getAPI(1); + this.initialized = true; + } + + isInitialized(): boolean { + return this.initialized && this.api !== null; + } + + private getRepo(fileUri?: vscode.Uri): Repository { + if (!this.api) { + throw new Error("Git service not initialized"); + } + // In multi-root workspaces, find the repo for the given file + if (fileUri) { + const repo = this.api.getRepository(fileUri); + if (repo) return repo; + } + // Fall back to active editor's repo, then first repo + const activeUri = vscode.window.activeTextEditor?.document.uri; + if (activeUri) { + const repo = this.api.getRepository(activeUri); + if (repo) return repo; + } + if (!this.api.repositories[0]) { + throw new Error("No repository found"); + } + return this.api.repositories[0]; + } + + async getChanges(): Promise { + const repo = this.getRepo(); + const statusMap = [ + "modified", // 0 + "added", // 1 + "deleted", // 2 + "renamed", // 3 + "copied", // 4 + "modified", // 5 + "deleted", // 6 + "untracked", // 7 + ]; + + const mapChange = (c: Change): FileChange => ({ + path: vscode.workspace.asRelativePath(c.uri), + status: statusMap[c.status] || "unknown", + }); + + return { + staged: repo.state.indexChanges.map(mapChange), + unstaged: repo.state.workingTreeChanges.map(mapChange), + }; + } + + async getDiff(filePath: string): Promise { + const fileUri = filePath.startsWith("/") + ? vscode.Uri.file(filePath) + : undefined; + const repo = this.getRepo(fileUri); + + // Find workspace root to resolve relative path + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + throw new Error("No workspace folder"); + } + + // diffWithHEAD expects relative path + const relativePath = filePath.startsWith("/") + ? vscode.workspace.asRelativePath(filePath) + : filePath; + + // Validate path + if (!isValidFilePath(relativePath)) { + throw new Error("Invalid file path"); + } + + try { + return await repo.diffWithHEAD(relativePath); + } catch (err) { + // If diff fails, return empty string (might be a new file) + console.error("[TaskSync Git] Diff failed for", relativePath, err); + return ""; + } + } + + async stage(filePath: string): Promise { + const fileUri = filePath.startsWith("/") + ? vscode.Uri.file(filePath) + : undefined; + const repo = this.getRepo(fileUri); + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) throw new Error("No workspace folder"); + + // Git add expects relative paths + const relativePath = filePath.startsWith("/") + ? vscode.workspace.asRelativePath(filePath) + : filePath; + + // Validate path + if (!isValidFilePath(relativePath)) { + throw new Error("Invalid file path"); + } + + await repo.add([relativePath]); + } + + async stageAll(): Promise { + const repo = this.getRepo(); + const paths = repo.state.workingTreeChanges.map((c) => + vscode.workspace.asRelativePath(c.uri), + ); + if (paths.length > 0) { + await repo.add(paths); + } + } + + async unstage(filePath: string): Promise { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) throw new Error("No workspace folder"); + + const relativePath = filePath.startsWith("/") + ? vscode.workspace.asRelativePath(filePath) + : filePath; + + // Validate path to prevent any malicious input + if (!isValidFilePath(relativePath)) { + throw new Error("Invalid file path"); + } + + // Use spawn with arguments array to completely avoid shell injection + const { spawn } = await import("node:child_process"); + await new Promise((resolve, reject) => { + const proc = spawn("git", ["reset", "HEAD", "--", relativePath], { + cwd: workspaceRoot, + }); + let stderr = ""; + proc.stderr.on("data", (data) => { + stderr += data; + }); + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(stderr || `git reset failed with code ${code}`)); + }); + proc.on("error", reject); + }); + } + + async discard(filePath: string): Promise { + const fileUri = filePath.startsWith("/") + ? vscode.Uri.file(filePath) + : undefined; + const repo = this.getRepo(fileUri); + const relativePath = filePath.startsWith("/") + ? vscode.workspace.asRelativePath(filePath) + : filePath; + + // Validate path + if (!isValidFilePath(relativePath)) { + throw new Error("Invalid file path"); + } + + await repo.clean([relativePath]); + } + + async commit(message: string): Promise { + if (!message || !message.trim()) { + throw new Error("Commit message required"); + } + + const repo = this.getRepo(); + + // Check if there are staged changes + if (repo.state.indexChanges.length === 0) { + throw new Error("Nothing to commit"); + } + + await repo.commit(message.trim()); + } + + async push(): Promise { + const repo = this.getRepo(); + await repo.push(); + } +} diff --git a/tasksync-chat/src/server/remoteAuthService.test.ts b/tasksync-chat/src/server/remoteAuthService.test.ts new file mode 100644 index 0000000..ef211c5 --- /dev/null +++ b/tasksync-chat/src/server/remoteAuthService.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it, vi } from "vitest"; +import { RemoteAuthService } from "./remoteAuthService"; + +// ─── Helpers ───────────────────────────────────────────────── + +function createMockContext() { + const store = new Map(); + return { + globalState: { + get: (key: string) => store.get(key), + update: (key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }, + }, + } as any; +} + +function createMockWs() { + const sent: string[] = []; + return { + send: (data: string) => sent.push(data), + _sent: sent, + _parsed: () => sent.map((s) => JSON.parse(s)), + }; +} + +function createService(pin = "123456") { + const ctx = createMockContext(); + const svc = new RemoteAuthService(ctx); + svc.pin = pin; + svc.pinEnabled = true; + return { svc, ctx }; +} + +const DUMMY_STATE = { pending: null, queue: [] }; +const GET_STATE = () => DUMMY_STATE; + +// ─── normalizeIp ───────────────────────────────────────────── + +describe("normalizeIp", () => { + it("strips IPv6-mapped IPv4 prefix", () => { + const { svc } = createService(); + expect(svc.normalizeIp("::ffff:127.0.0.1")).toBe("127.0.0.1"); + }); + + it("passes through plain IPv4", () => { + const { svc } = createService(); + expect(svc.normalizeIp("192.168.1.1")).toBe("192.168.1.1"); + }); + + it("passes through IPv6", () => { + const { svc } = createService(); + expect(svc.normalizeIp("::1")).toBe("::1"); + }); +}); + +// ─── handleAuth — no-PIN mode ──────────────────────────────── + +describe("handleAuth — no-PIN mode", () => { + it("authenticates and sends authSuccess when client not yet authenticated", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + + svc.handleAuth(ws as any, "1.2.3.4", undefined, undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(true); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + expect(msgs[0].gitServiceAvailable).toBe(true); + }); + + it("does not send duplicate authSuccess for already-authenticated client", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.handleAuth( + ws as any, + "1.2.3.4", + undefined, + undefined, + GET_STATE, + false, + ); + + expect(ws._sent).toHaveLength(0); + }); +}); + +// ─── handleAuth — PIN auth ─────────────────────────────────── + +describe("handleAuth — PIN auth", () => { + it("authenticates with correct PIN and returns session token", () => { + const { svc } = createService("654321"); + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.1", "654321", undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(true); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + expect(msgs[0].sessionToken).toBeDefined(); + expect(msgs[0].sessionToken).toMatch(/^[a-f0-9]{64}$/); + }); + + it("rejects wrong PIN and tracks failed attempt", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.1", "000000", undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(false); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Wrong PIN"); + expect(msgs[0].message).toContain("4 attempts left"); + }); + + it("locks out after 5 consecutive failed attempts", () => { + const { svc } = createService("123456"); + const ip = "10.0.0.99"; + + for (let i = 0; i < 5; i++) { + const ws = createMockWs(); + svc.handleAuth(ws as any, ip, "wrong!", undefined, GET_STATE, true); + } + + // 6th attempt should show lockout + const ws = createMockWs(); + svc.handleAuth(ws as any, ip, "123456", undefined, GET_STATE, true); + const msgs = ws._parsed(); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Locked"); + }); + + it("calls onAuthFailure callback on failed attempts", () => { + const { svc } = createService("123456"); + const failureCb = vi.fn(); + svc.onAuthFailure = failureCb; + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.5", "wrong", undefined, GET_STATE, true); + + expect(failureCb).toHaveBeenCalledWith("10.0.0.5", 1, false); + }); + + it("sends error when getState throws", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + const badGetState = () => { + throw new Error("state failure"); + }; + + svc.handleAuth( + ws as any, + "10.0.0.1", + undefined, + undefined, + badGetState, + true, + ); + + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("error"); + }); +}); + +// ─── handleAuth — session token auth ───────────────────────── + +describe("handleAuth — session token auth", () => { + it("authenticates with valid session token and rotates it", () => { + const { svc } = createService("123456"); + const ip = "10.0.0.1"; + + // First: authenticate with PIN to get a session token + const ws1 = createMockWs(); + svc.handleAuth(ws1 as any, ip, "123456", undefined, GET_STATE, true); + const token1 = ws1._parsed()[0].sessionToken; + expect(token1).toMatch(/^[a-f0-9]{64}$/); + + // Clean up first ws from authenticated set + svc.removeClient(ws1 as any); + + // Second: reconnect with session token + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, ip, undefined, token1, GET_STATE, true); + const msgs = ws2._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + // Token should be rotated + expect(msgs[0].sessionToken).toBeDefined(); + expect(msgs[0].sessionToken).not.toBe(token1); + }); + + it("rejects session token from different IP", () => { + const { svc } = createService("123456"); + + // Authenticate from IP A + const ws1 = createMockWs(); + svc.handleAuth( + ws1 as any, + "10.0.0.1", + "123456", + undefined, + GET_STATE, + true, + ); + const token = ws1._parsed()[0].sessionToken; + + // Try to use token from IP B — should fall through to PIN auth and fail + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, "10.0.0.2", undefined, token, GET_STATE, true); + const msgs = ws2._parsed(); + expect(msgs[0].type).toBe("authFailed"); + }); + + it("rejects malformed session token and falls through to PIN auth", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + + svc.handleAuth( + ws as any, + "10.0.0.1", + undefined, + "not-valid-hex", + GET_STATE, + true, + ); + + const msgs = ws._parsed(); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Wrong PIN"); + }); +}); + +// ─── verifyHttpAuth ────────────────────────────────────────── + +describe("verifyHttpAuth", () => { + function createMockReq( + headerPin?: string, + ip = "10.0.0.1", + ): import("http").IncomingMessage { + return { + headers: headerPin ? { "x-tasksync-pin": headerPin } : {}, + socket: { remoteAddress: ip }, + } as any; + } + + it("allows all requests when PIN is disabled", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const req = createMockReq(); + const url = new URL("http://localhost/api/test"); + + expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); + }); + + it("allows requests with correct PIN header", () => { + const { svc } = createService("654321"); + const req = createMockReq("654321"); + const url = new URL("http://localhost/api/test"); + + expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); + }); + + it("rejects requests with wrong PIN header", () => { + const { svc } = createService("654321"); + const req = createMockReq("000000"); + const url = new URL("http://localhost/api/test"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("rejects requests with no PIN", () => { + const { svc } = createService("654321"); + const req = createMockReq(); + const url = new URL("http://localhost/api/test"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("no longer accepts PIN via query string", () => { + const { svc } = createService("654321"); + const req = createMockReq(); + const url = new URL("http://localhost/api/test?pin=654321"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("locks out after repeated failures", () => { + const { svc } = createService("654321"); + const url = new URL("http://localhost/api/test"); + + for (let i = 0; i < 5; i++) { + const req = createMockReq("wrong", "10.0.0.50"); + svc.verifyHttpAuth(req, url); + } + + const req = createMockReq("654321", "10.0.0.50"); + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + expect(result.lockedOut).toBe(true); + }); +}); + +// ─── getOrCreatePin ────────────────────────────────────────── + +describe("getOrCreatePin", () => { + it("generates a 6-digit PIN when none exists", () => { + const ctx = createMockContext(); + const svc = new RemoteAuthService(ctx); + + const pin = svc.getOrCreatePin(); + expect(pin).toMatch(/^\d{6}$/); + }); + + it("returns persisted PIN from globalState", () => { + const ctx = createMockContext(); + ctx.globalState.update("remotePin", "987654"); + const svc = new RemoteAuthService(ctx); + + expect(svc.getOrCreatePin()).toBe("987654"); + }); + + it("upgrades short PINs to 6 digits", () => { + const ctx = createMockContext(); + ctx.globalState.update("remotePin", "1234"); + const svc = new RemoteAuthService(ctx); + + const pin = svc.getOrCreatePin(); + expect(pin).toMatch(/^\d{6}$/); + expect(pin).not.toBe("1234"); + }); +}); + +// ─── cleanup / removeClient / clearSessionTokens ──────────── + +describe("lifecycle methods", () => { + it("removeClient removes from authenticated set", () => { + const { svc } = createService(); + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.removeClient(ws as any); + expect(svc.authenticatedClients.has(ws as any)).toBe(false); + }); + + it("clearSessionTokens clears all tokens", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + svc.handleAuth(ws as any, "10.0.0.1", "123456", undefined, GET_STATE, true); + + svc.clearSessionTokens(); + + // Token from earlier auth should no longer work + const token = ws._parsed()[0].sessionToken; + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, "10.0.0.1", undefined, token, GET_STATE, true); + expect(ws2._parsed()[0].type).toBe("authFailed"); + }); + + it("cleanup clears all state", () => { + const { svc } = createService(); + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.cleanup(); + + expect(svc.authenticatedClients.size).toBe(0); + }); + + it("startFailedAttemptsCleanup does not throw when called twice", () => { + const { svc } = createService(); + svc.startFailedAttemptsCleanup(); + svc.startFailedAttemptsCleanup(); + svc.cleanup(); // clean up timer + }); +}); diff --git a/tasksync-chat/src/server/remoteAuthService.ts b/tasksync-chat/src/server/remoteAuthService.ts new file mode 100644 index 0000000..cf5eeb1 --- /dev/null +++ b/tasksync-chat/src/server/remoteAuthService.ts @@ -0,0 +1,357 @@ +import * as crypto from "crypto"; +import * as vscode from "vscode"; +import type { WebSocket } from "ws"; +import { + CONFIG_SECTION, + WS_PROTOCOL_VERSION, +} from "../constants/remoteConstants"; +import { getSafeErrorMessage, sendWsError } from "./serverUtils"; + +/** + * Handles authentication for the remote server. + * Manages PIN auth, session tokens, failed attempts, and lockouts. + */ +export class RemoteAuthService { + // Authentication state + pinEnabled: boolean = true; + pin: string = ""; + readonly authenticatedClients: Set = new Set(); + + /** Timing-safe PIN comparison to prevent timing attacks. */ + private comparePinTimingSafe(input: string): boolean { + if (input.length !== this.pin.length) return false; + const pinBuffer = Buffer.from(this.pin, "utf8"); + const inputBuffer = Buffer.from(input, "utf8"); + if (pinBuffer.length !== inputBuffer.length) return false; + return crypto.timingSafeEqual(pinBuffer, inputBuffer); + } + private failedAttempts: Map< + string, + { count: number; lockUntil: number; lastAttemptAt: number } + > = new Map(); + private sessionTokens: Map = + new Map(); + private failedAttemptsCleanupTimer: ReturnType | null = + null; + + // Configuration constants + private readonly SESSION_TOKEN_EXPIRY_MS = 4 * 60 * 60 * 1000; // 4 hours + private readonly MAX_SESSION_TOKENS = 100; + private readonly SESSION_TOKEN_PATTERN = /^[a-f0-9]{64}$/; + private readonly MAX_ATTEMPTS = 5; + private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes + private readonly MAX_FAILED_ATTEMPTS_ENTRIES = 1000; + + /** Callback for failed auth attempts (used by server to notify VS Code) */ + onAuthFailure?: ( + clientIp: string, + attemptCount: number, + lockedOut: boolean, + ) => void; + + /** + * Get the current attempt entry, automatically clearing expired lockouts. + */ + private getActiveAttempt( + clientIp: string, + ): { count: number; lockUntil: number; lastAttemptAt: number } | undefined { + const attempt = this.failedAttempts.get(clientIp); + if (!attempt) { + return undefined; + } + + if (attempt.lockUntil > 0 && attempt.lockUntil <= Date.now()) { + this.failedAttempts.delete(clientIp); + return undefined; + } + + return attempt; + } + + constructor(private context: vscode.ExtensionContext) {} + + /** + * Handle PIN/session-token authentication for a WebSocket client. + */ + handleAuth( + ws: WebSocket, + clientIp: string, + pin: string | undefined, + sessionToken: string | undefined, + getState: () => unknown, + gitServiceAvailable: boolean, + ): void { + let state; + try { + state = getState(); + } catch (err) { + console.error( + "[TaskSync Remote] Error getting state:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "Internal server error"); + return; + } + + if (!this.pinEnabled) { + // In no-PIN mode, handleConnection already authenticated this client + // and sent 'connected' with state. Skip duplicate authSuccess. + if (!this.authenticatedClients.has(ws)) { + this.authenticatedClients.add(ws); + ws.send( + JSON.stringify({ + type: "authSuccess", + state, + gitServiceAvailable, + protocolVersion: WS_PROTOCOL_VERSION, + }), + ); + } + return; + } + + // Try session token auth first (for reconnections) + if (sessionToken && typeof sessionToken === "string") { + if (!this.SESSION_TOKEN_PATTERN.test(sessionToken)) { + // Ignore malformed token and continue to PIN auth path. + sessionToken = undefined; + } + } + + if (sessionToken && typeof sessionToken === "string") { + const tokenData = this.sessionTokens.get(sessionToken); + if ( + tokenData && + tokenData.clientIp === clientIp && + tokenData.expiresAt > Date.now() + ) { + // Valid session token - authenticate without PIN + // Rotate token on use to reduce replay window. + this.sessionTokens.delete(sessionToken); + const rotatedSessionToken = this.generateSessionToken(clientIp); + this.authenticatedClients.add(ws); + ws.send( + JSON.stringify({ + type: "authSuccess", + state, + gitServiceAvailable, + sessionToken: rotatedSessionToken, + protocolVersion: WS_PROTOCOL_VERSION, + }), + ); + return; + } + // Invalid/expired token, fall through to PIN auth + this.sessionTokens.delete(sessionToken); + } + + const attempt = this.getActiveAttempt(clientIp); + if (attempt && attempt.lockUntil > Date.now()) { + const remaining = Math.ceil((attempt.lockUntil - Date.now()) / 60000); + ws.send( + JSON.stringify({ + type: "authFailed", + message: `Locked. ${remaining}m remaining.`, + }), + ); + return; + } + + // Timing-safe comparison + const valid = this.comparePinTimingSafe(String(pin || "")); + + if (!valid) { + const count = (attempt?.count ?? 0) + 1; + const lockedOut = count >= this.MAX_ATTEMPTS; + this.failedAttempts.set(clientIp, { + count, + lockUntil: lockedOut ? Date.now() + this.LOCKOUT_MS : 0, + lastAttemptAt: Date.now(), + }); + const remainingAttempts = Math.max(0, this.MAX_ATTEMPTS - count); + ws.send( + JSON.stringify({ + type: "authFailed", + message: lockedOut + ? `Too many failed attempts. Locked for ${Math.ceil(this.LOCKOUT_MS / 60000)}m.` + : `Wrong PIN. ${remainingAttempts} attempts left.`, + }), + ); + this.onAuthFailure?.(clientIp, count, lockedOut); + return; + } + + this.failedAttempts.delete(clientIp); + this.authenticatedClients.add(ws); + + // Generate session token for future reconnections + const newSessionToken = this.generateSessionToken(clientIp); + + ws.send( + JSON.stringify({ + type: "authSuccess", + state, + gitServiceAvailable, + sessionToken: newSessionToken, + protocolVersion: WS_PROTOCOL_VERSION, + }), + ); + } + + /** + * Generate and store a session token for the client. + */ + private generateSessionToken(clientIp: string): string { + // Cleanup expired tokens and enforce limit + const now = Date.now(); + for (const [token, data] of this.sessionTokens) { + if (data.expiresAt < now) { + this.sessionTokens.delete(token); + } + } + // If still at limit, remove oldest + if (this.sessionTokens.size >= this.MAX_SESSION_TOKENS) { + const oldest = this.sessionTokens.keys().next().value; + if (oldest) this.sessionTokens.delete(oldest); + } + + const token = crypto.randomBytes(32).toString("hex"); + this.sessionTokens.set(token, { + clientIp, + expiresAt: now + this.SESSION_TOKEN_EXPIRY_MS, + }); + return token; + } + + /** + * Verify HTTP API authentication via PIN header or query param. + * Includes failed-attempt tracking and lockout (mirrors WebSocket auth). + */ + verifyHttpAuth( + req: import("http").IncomingMessage, + url: URL, + ): { allowed: boolean; lockedOut?: boolean } { + if (!this.pinEnabled) return { allowed: true }; + + const clientIp = this.normalizeIp(req.socket.remoteAddress || ""); + + // Check lockout (shared with WebSocket auth) + const attempt = this.getActiveAttempt(clientIp); + if (attempt && attempt.lockUntil > Date.now()) { + return { allowed: false, lockedOut: true }; + } + + const headerPin = req.headers["x-tasksync-pin"] as string | undefined; + const suppliedPin = headerPin || ""; + + const valid = this.comparePinTimingSafe(suppliedPin); + + if (!valid) { + const count = (attempt?.count ?? 0) + 1; + const lockedOut = count >= this.MAX_ATTEMPTS; + this.failedAttempts.set(clientIp, { + count, + lockUntil: lockedOut ? Date.now() + this.LOCKOUT_MS : 0, + lastAttemptAt: Date.now(), + }); + this.onAuthFailure?.(clientIp, count, lockedOut); + return { allowed: false }; + } + + this.failedAttempts.delete(clientIp); + return { allowed: true }; + } + + /** + * Get or create a persistent PIN for the remote server. + */ + getOrCreatePin(): string { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const customPin = config.get("remotePin", ""); + + if (customPin && /^\d{4,6}$/.test(customPin)) { + return customPin; + } + + // Use persisted PIN or generate new (upgrade short PINs to 6 digits) + let pin = this.context.globalState.get("remotePin"); + if (!pin || pin.length < 6) { + pin = crypto.randomInt(100000, 1000000).toString(); + this.context.globalState.update("remotePin", pin); + } + return pin; + } + + /** + * Clear all session tokens (e.g., on PIN change). + */ + clearSessionTokens(): void { + this.sessionTokens.clear(); + } + + /** + * Start periodic cleanup of expired failed attempt entries. + */ + private readonly STALE_ATTEMPT_MS = 60 * 60 * 1000; // 1 hour — cleanup unlocked entries + + startFailedAttemptsCleanup(): void { + if (this.failedAttemptsCleanupTimer) return; + this.failedAttemptsCleanupTimer = setInterval( + () => { + const now = Date.now(); + for (const [ip, attempt] of this.failedAttempts.entries()) { + // Remove expired lockouts + if (attempt.lockUntil > 0 && attempt.lockUntil < now) { + this.failedAttempts.delete(ip); + continue; + } + // Remove stale entries that never triggered lockout (below threshold) + if ( + attempt.lockUntil === 0 && + now - attempt.lastAttemptAt > this.STALE_ATTEMPT_MS + ) { + this.failedAttempts.delete(ip); + } + } + if (this.failedAttempts.size > this.MAX_FAILED_ATTEMPTS_ENTRIES) { + const toDelete = + this.failedAttempts.size - this.MAX_FAILED_ATTEMPTS_ENTRIES; + let deleted = 0; + for (const ip of this.failedAttempts.keys()) { + if (deleted >= toDelete) break; + this.failedAttempts.delete(ip); + deleted++; + } + } + }, + 5 * 60 * 1000, + ); + } + + /** + * Clean up all auth state (called on server stop). + */ + cleanup(): void { + if (this.failedAttemptsCleanupTimer) { + clearInterval(this.failedAttemptsCleanupTimer); + this.failedAttemptsCleanupTimer = null; + } + this.authenticatedClients.clear(); + this.failedAttempts.clear(); + this.sessionTokens.clear(); + } + + /** + * Remove a client from authenticated set (called on disconnect). + */ + removeClient(ws: WebSocket): void { + this.authenticatedClients.delete(ws); + } + + /** + * Normalize client IP address (strip IPv6-mapped IPv4 prefix). + */ + normalizeIp(ip: string): string { + return ip.replace(/^::ffff:/, ""); + } +} diff --git a/tasksync-chat/src/server/remoteGitHandlers.ts b/tasksync-chat/src/server/remoteGitHandlers.ts new file mode 100644 index 0000000..5612c10 --- /dev/null +++ b/tasksync-chat/src/server/remoteGitHandlers.ts @@ -0,0 +1,341 @@ +import type { WebSocket } from "ws"; +import { + ErrorCode, + MAX_COMMIT_MESSAGE_LENGTH, + MAX_FILE_PATH_LENGTH, + MAX_SEARCH_QUERY_LENGTH, + truncateDiff, +} from "../constants/remoteConstants"; +import type { GitService } from "./gitService"; +import { isValidFilePath } from "./gitService"; +import { getSafeErrorMessage, sendWsError } from "./serverUtils"; + +/** + * Broadcast function type for notifying all connected clients. + */ +type BroadcastFn = (type: string, data: unknown) => void; + +/** + * Guard: send GIT_UNAVAILABLE error if git is not available. + * Returns true if git IS available, false (and sends error) if not. + */ +function requireGitService(ws: WebSocket, available: boolean): boolean { + if (!available) { + sendWsError(ws, "Git service is not available", ErrorCode.GIT_UNAVAILABLE); + } + return available; +} + +/** + * Handle getChanges request - returns list of modified files. + */ +export async function handleGetChanges( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + const changes = await gitService.getChanges(); + ws.send(JSON.stringify({ type: "changes", data: changes })); + } catch (err) { + console.error( + "[TaskSync Remote] getChanges error:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "Failed to get changes"); + } +} + +/** + * Handle getDiff request - returns diff for a specific file. + */ +export async function handleGetDiff( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + filePath: string, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + const diff = truncateDiff(await gitService.getDiff(filePath)); + ws.send(JSON.stringify({ type: "diff", file: filePath, data: diff })); + } catch (err) { + console.error("[TaskSync Remote] getDiff error:", getSafeErrorMessage(err)); + sendWsError(ws, "Failed to get diff"); + } +} + +/** + * Handle stageFile request - stages a file and broadcasts update. + */ +export async function handleStageFile( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + broadcast: BroadcastFn, + filePath: string, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + await gitService.stage(filePath); + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + ws.send(JSON.stringify({ type: "staged", file: filePath })); + } catch (err) { + console.error( + "[TaskSync Remote] stageFile error:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "Failed to stage file"); + } +} + +/** + * Handle unstageFile request - unstages a file and broadcasts update. + */ +export async function handleUnstageFile( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + broadcast: BroadcastFn, + filePath: string, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + await gitService.unstage(filePath); + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + ws.send(JSON.stringify({ type: "unstaged", file: filePath })); + } catch (err) { + console.error( + "[TaskSync Remote] unstageFile error:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "Failed to unstage file"); + } +} + +/** + * Handle stageAll request - stages all files and broadcasts update. + */ +export async function handleStageAll( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + broadcast: BroadcastFn, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + await gitService.stageAll(); + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + ws.send(JSON.stringify({ type: "stagedAll" })); + } catch (err) { + console.error( + "[TaskSync Remote] stageAll error:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "Failed to stage files"); + } +} + +/** + * Handle discardFile request - discards changes to a file and broadcasts update. + */ +export async function handleDiscardFile( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + broadcast: BroadcastFn, + filePath: string, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + await gitService.discard(filePath); + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + ws.send(JSON.stringify({ type: "discarded", file: filePath })); + } catch (err) { + console.error( + "[TaskSync Remote] discardFile error:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "Failed to discard changes"); + } +} + +/** + * Handle commit request - commits staged changes and broadcasts update. + */ +export async function handleCommit( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + broadcast: BroadcastFn, + message: string, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + // Validate commit message + const trimmed = (message || "").trim(); + if (!trimmed || trimmed.length > MAX_COMMIT_MESSAGE_LENGTH) { + sendWsError(ws, "Invalid commit message"); + return; + } + try { + await gitService.commit(trimmed); + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + ws.send(JSON.stringify({ type: "committed" })); + } catch (err) { + console.error("[TaskSync Remote] commit error:", getSafeErrorMessage(err)); + sendWsError(ws, "Failed to commit"); + } +} + +/** + * Handle push request - pushes commits to remote. + */ +export async function handlePush( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, +): Promise { + if (!requireGitService(ws, gitServiceAvailable)) return; + try { + await gitService.push(); + ws.send(JSON.stringify({ type: "pushed" })); + } catch (err) { + console.error("[TaskSync Remote] push error:", getSafeErrorMessage(err)); + sendWsError(ws, "Failed to push"); + } +} + +/** + * Handle searchFiles request - searches workspace files. + */ +export async function handleSearchFiles( + ws: WebSocket, + searchFn: (query: string) => Promise, + query: string, +): Promise { + // Validate query length (allow empty for tool listing) + if (query && query.length > MAX_SEARCH_QUERY_LENGTH) { + ws.send(JSON.stringify({ type: "fileSearchResults", files: [] })); + return; + } + try { + const files = await searchFn(query); + ws.send(JSON.stringify({ type: "fileSearchResults", files })); + } catch (err) { + console.error( + "[TaskSync Remote] searchFiles error:", + getSafeErrorMessage(err), + ); + sendWsError(ws, "File search failed"); + } +} + +/** + * Validate a file path from a remote message. + */ +function validateFilePath(ws: WebSocket, filePath: unknown): string | null { + if ( + typeof filePath !== "string" || + !filePath || + filePath.length > MAX_FILE_PATH_LENGTH + ) { + sendWsError(ws, "Invalid file path", ErrorCode.INVALID_INPUT); + return null; + } + if (!isValidFilePath(filePath)) { + sendWsError(ws, "Invalid file path", ErrorCode.INVALID_INPUT); + return null; + } + return filePath; +} + +/** + * Dispatch a git-related WebSocket message to the appropriate handler. + * Returns true if the message type was handled, false otherwise. + */ +export async function dispatchGitMessage( + ws: WebSocket, + gitService: GitService, + gitServiceAvailable: boolean, + broadcast: BroadcastFn, + searchFn: (query: string) => Promise, + msg: { type: string; [key: string]: unknown }, +): Promise { + switch (msg.type) { + case "getChanges": + await handleGetChanges(ws, gitService, gitServiceAvailable); + return true; + case "getDiff": { + const file = validateFilePath(ws, msg.file); + if (!file) return true; + await handleGetDiff(ws, gitService, gitServiceAvailable, file); + return true; + } + case "stageFile": { + const file = validateFilePath(ws, msg.file); + if (!file) return true; + await handleStageFile( + ws, + gitService, + gitServiceAvailable, + broadcast, + file, + ); + return true; + } + case "unstageFile": { + const file = validateFilePath(ws, msg.file); + if (!file) return true; + await handleUnstageFile( + ws, + gitService, + gitServiceAvailable, + broadcast, + file, + ); + return true; + } + case "stageAll": + await handleStageAll(ws, gitService, gitServiceAvailable, broadcast); + return true; + case "discardFile": { + const file = validateFilePath(ws, msg.file); + if (!file) return true; + await handleDiscardFile( + ws, + gitService, + gitServiceAvailable, + broadcast, + file, + ); + return true; + } + case "commitChanges": + await handleCommit( + ws, + gitService, + gitServiceAvailable, + broadcast, + typeof msg.message === "string" ? msg.message : "", + ); + return true; + case "pushChanges": + await handlePush(ws, gitService, gitServiceAvailable); + return true; + case "searchFiles": + await handleSearchFiles( + ws, + searchFn, + typeof msg.query === "string" ? msg.query : "", + ); + return true; + default: + return false; + } +} diff --git a/tasksync-chat/src/server/remoteHtmlService.ts b/tasksync-chat/src/server/remoteHtmlService.ts new file mode 100644 index 0000000..920da0b --- /dev/null +++ b/tasksync-chat/src/server/remoteHtmlService.ts @@ -0,0 +1,598 @@ +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; +import { + MAX_COMMIT_MESSAGE_LENGTH, + MAX_FILE_PATH_LENGTH, + truncateDiff, +} from "../constants/remoteConstants"; +import type { FileSearchResult } from "../webview/webviewProvider"; +import type { GitService } from "./gitService"; +import { isValidFilePath } from "./gitService"; +import type { RemoteAuthService } from "./remoteAuthService"; +import { + getSafeErrorMessage, + isOriginAllowed, + setSecurityHeaders, +} from "./serverUtils"; + +/** Map file extensions to content types for static file serving. */ +const CONTENT_TYPES: Record = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +/** + * Handles HTTP requests, file serving, and HTML generation for the remote server. + */ +export class RemoteHtmlService { + private _cachedBodyTemplate: string | null = null; + private readonly MAX_BODY_SIZE = 1024 * 1024; // 1MB + /** Set by RemoteServer when TLS is active, enables HSTS header. */ + public tlsEnabled = false; + + constructor( + private webDir: string, + private mediaDir: string, + ) {} + + /** + * Preload HTML templates asynchronously during server startup. + * This avoids blocking the event loop on first request. + */ + async preloadTemplates(): Promise { + if (this._cachedBodyTemplate) return; + + const templatePath = path.join(this.mediaDir, "webview-body.html"); + try { + this._cachedBodyTemplate = await fs.promises.readFile( + templatePath, + "utf8", + ); + } catch (err) { + console.error( + "[TaskSync Remote] Error preloading template:", + getSafeErrorMessage(err), + ); + } + } + + /** Main HTTP request handler - routes to appropriate handler. */ + handleHttp( + req: http.IncomingMessage, + res: http.ServerResponse, + authService: RemoteAuthService, + gitService: GitService, + gitServiceAvailable: boolean, + provider: { + searchFilesForRemote: (query: string) => Promise; + }, + broadcast?: (type: string, data: unknown) => void, + ): void { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + + // Handle API endpoints + if (url.pathname.startsWith("/api/")) { + this.handleApi( + req, + res, + url, + authService, + gitService, + gitServiceAvailable, + provider, + broadcast, + ).catch((err) => { + console.error("[TaskSync Remote] API error:", err); + if (!res.headersSent) { + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal error" })); + } + }); + return; + } + + // Route: /app.html - serve the main app (VS Code webview) + if (url.pathname === "/app.html") { + this.serveRemoteApp(res, req.headers.host || ""); + return; + } + + // Route: /shared-constants.js - serve shared constants (SSOT for frontend) + if (url.pathname === "/shared-constants.js") { + const sharedConstantsPath = path.join(this.webDir, "shared-constants.js"); + this.serveFile(sharedConstantsPath, res); + return; + } + + // Route: /media/* - serve from media folder (VS Code webview assets) + if (url.pathname.startsWith("/media/")) { + const decodedPath = decodeURIComponent(url.pathname.slice(7)); + const normalizedPath = path + .normalize(decodedPath) + .replace(/^(\.\.[\/\\])+/, ""); + const fullPath = path.resolve( + this.mediaDir, + normalizedPath.replace(/^[\/\\]+/, ""), + ); + const canonicalMediaDir = path.resolve(this.mediaDir); + + if ( + !fullPath.startsWith(canonicalMediaDir + path.sep) && + fullPath !== canonicalMediaDir + ) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + this.serveFile(fullPath, res); + return; + } + + // Route: /codicon.css - serve codicons from node_modules + if (url.pathname === "/codicon.css") { + const codiconPath = path.join( + path.dirname(this.mediaDir), + "node_modules", + "@vscode", + "codicons", + "dist", + "codicon.css", + ); + this.serveFile(codiconPath, res); + return; + } + + // Route: /codicon.ttf - serve codicon font + if (url.pathname === "/codicon.ttf") { + const codiconPath = path.join( + path.dirname(this.mediaDir), + "node_modules", + "@vscode", + "codicons", + "dist", + "codicon.ttf", + ); + this.serveFile(codiconPath, res); + return; + } + + // Default: serve from web folder (login page, etc) + let filePath = url.pathname === "/" ? "/index.html" : url.pathname; + + const decodedPath = decodeURIComponent(filePath); + const normalizedPath = path + .normalize(decodedPath) + .replace(/^(\.\.[\/\\])+/, ""); + const fullPath = path.resolve( + this.webDir, + normalizedPath.replace(/^[\/\\]+/, ""), + ); + const canonicalWebDir = path.resolve(this.webDir); + + if ( + !fullPath.startsWith(canonicalWebDir + path.sep) && + fullPath !== canonicalWebDir + ) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const requestHost = req.headers.host || ""; + + // For index.html, add a dynamic CSP header with specific ws origin + const isLoginPage = url.pathname === "/" || url.pathname === "/index.html"; + const cspHeader = isLoginPage ? this.buildLoginCsp(requestHost) : undefined; + + this.serveFile( + fullPath, + res, + () => { + if (!path.extname(fullPath)) { + this.serveRemoteApp(res, requestHost); + } else { + res.writeHead(404); + res.end("Not Found"); + } + }, + cspHeader, + ); + } + + /** + * Serve a static file with symlink protection. + * Uses realpath to atomically resolve symlinks and verify the canonical path + * is within allowed directories, preventing TOCTOU race conditions. + */ + serveFile( + fullPath: string, + res: http.ServerResponse, + onNotFound?: () => void, + cspOverride?: string, + ): void { + const ext = path.extname(fullPath).toLowerCase(); + const canonicalWebDir = path.resolve(this.webDir); + const canonicalMediaDir = path.resolve(this.mediaDir); + // Also allow codicon assets from node_modules + const canonicalNodeModules = path.resolve( + path.dirname(this.mediaDir), + "node_modules", + ); + + // Atomically resolve symlinks and verify the canonical path + fs.realpath(fullPath, (realpathErr, resolvedPath) => { + if (realpathErr) { + if (onNotFound) { + onNotFound(); + } else { + res.writeHead(404); + res.end("Not Found"); + } + return; + } + + // Verify resolved path is within allowed directories + const inWebDir = + resolvedPath.startsWith(canonicalWebDir + path.sep) || + resolvedPath === canonicalWebDir; + const inMediaDir = + resolvedPath.startsWith(canonicalMediaDir + path.sep) || + resolvedPath === canonicalMediaDir; + const inNodeModules = resolvedPath.startsWith( + canonicalNodeModules + path.sep, + ); + if (!inWebDir && !inMediaDir && !inNodeModules) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + fs.readFile(resolvedPath, (err, data) => { + if (err) { + if (onNotFound) { + onNotFound(); + } else { + res.writeHead(404); + res.end("Not Found"); + } + return; + } + + const headers: Record = { + "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", + "Cache-Control": "no-cache", + }; + if (cspOverride) { + headers["Content-Security-Policy"] = cspOverride; + } + setSecurityHeaders(res, this.tlsEnabled); + res.writeHead(200, headers); + res.end(data); + }); + }); + } + + /** + * Serve the main remote app HTML page. + */ + private serveRemoteApp(res: http.ServerResponse, host: string): void { + const html = this.generateRemoteAppHtml(host); + setSecurityHeaders(res, this.tlsEnabled); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(html); + } + + /** Build WebSocket origin directives from the request host. Falls back to broad `ws: wss:` if empty. */ + private buildWsOrigin(host: string): string { + if (!host) return "ws: wss:"; + return `ws://${host} wss://${host}`; + } + + /** Build a CSP string for the login page (no CDN, no media). */ + private buildLoginCsp(host: string): string { + const wsOrigin = this.buildWsOrigin(host); + return `default-src 'none'; style-src 'self'; script-src 'self'; font-src 'self'; img-src 'self' data:; connect-src 'self' ${wsOrigin}; manifest-src 'self';`; + } + + /** + * Generate the remote app HTML with template substitution. + */ + private generateRemoteAppHtml(host: string): string { + if (!this._cachedBodyTemplate) { + // Template not yet loaded — return a lightweight page that auto-refreshes + console.error( + "[TaskSync Remote] Template not preloaded, returning auto-refresh page", + ); + // Trigger async preload for next request + void this.preloadTemplates(); + return ` + + +

    Loading TaskSync Remote...

    `; + } + let bodyHtml = this._cachedBodyTemplate; + + bodyHtml = bodyHtml + .replace(/\{\{LOGO_URI\}\}/g, "/media/TS-logo.svg") + .replace(/\{\{TITLE\}\}/g, "TaskSync Remote") + .replace(/\{\{SUBTITLE\}\}/g, "Control your AI workflow from anywhere"); + + return ` + + + + + + + + + TaskSync Remote + + + + + + + + + + + +
    +
    + TaskSync + Connecting +
    +
    + + +
    +
    + + ${bodyHtml} + + + + + +`; + } + + /** + * Handle API endpoints (REST). + */ + private async handleApi( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + authService: RemoteAuthService, + gitService: GitService, + gitServiceAvailable: boolean, + provider: { + searchFilesForRemote: (query: string) => Promise; + }, + broadcast?: (type: string, data: unknown) => void, + ): Promise { + res.setHeader("Content-Type", "application/json"); + setSecurityHeaders(res, this.tlsEnabled); + + // Reject cross-origin API requests (defense-in-depth) + if (!isOriginAllowed(req)) { + res.writeHead(403); + res.end(JSON.stringify({ error: "Cross-origin request blocked" })); + return; + } + + const authResult = authService.verifyHttpAuth(req, url); + if (!authResult.allowed) { + const status = authResult.lockedOut ? 429 : 401; + const msg = authResult.lockedOut + ? "Too many attempts. Try again later." + : "Unauthorized"; + res.writeHead(status); + res.end(JSON.stringify({ error: msg })); + return; + } + + // File search API + if (url.pathname === "/api/files" && req.method === "GET") { + const query = url.searchParams.get("query") || ""; + if (query.length > MAX_FILE_PATH_LENGTH) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Query too long" })); + return; + } + const files = await provider.searchFilesForRemote(query); + res.writeHead(200); + res.end(JSON.stringify(files)); + return; + } + + // Git API endpoints - check availability first + if (url.pathname.startsWith("/api/") && url.pathname !== "/api/files") { + if (!gitServiceAvailable) { + res.writeHead(503); + res.end(JSON.stringify({ error: "Git service unavailable" })); + return; + } + } + + if (url.pathname === "/api/changes" && req.method === "GET") { + try { + const changes = await gitService.getChanges(); + res.writeHead(200); + res.end(JSON.stringify(changes)); + } catch { + res.writeHead(500); + res.end(JSON.stringify({ error: "Failed to get changes" })); + } + return; + } + + if (url.pathname === "/api/diff" && req.method === "GET") { + const file = url.searchParams.get("file"); + if ( + !file || + file.length > MAX_FILE_PATH_LENGTH || + !isValidFilePath(file) + ) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Invalid file path" })); + return; + } + try { + const diff = truncateDiff(await gitService.getDiff(file)); + res.writeHead(200); + res.end(JSON.stringify({ diff })); + } catch { + res.writeHead(500); + res.end(JSON.stringify({ error: "Failed to get diff" })); + } + return; + } + + // POST endpoints need body parsing + if (req.method === "POST") { + const chunks: Buffer[] = []; + let bodyLength = 0; + let aborted = false; + req.on("data", (chunk: Buffer) => { + bodyLength += chunk.length; + if (bodyLength > this.MAX_BODY_SIZE) { + aborted = true; + res.writeHead(413); + res.end(JSON.stringify({ error: "Request body too large" })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", async () => { + if (aborted) return; + try { + const body = Buffer.concat(chunks).toString("utf8"); + const data = body ? JSON.parse(body) : {}; + await this.handlePostApi( + url.pathname, + data, + res, + gitService, + broadcast, + ); + } catch { + res.writeHead(400); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + return; + } + + res.writeHead(404); + res.end(JSON.stringify({ error: "Not found" })); + } + + /** + * Handle POST API endpoints. + */ + private async handlePostApi( + pathname: string, + data: Record, + res: http.ServerResponse, + gitService: GitService, + broadcast?: (type: string, data: unknown) => void, + ): Promise { + try { + switch (pathname) { + case "/api/stage": + case "/api/unstage": + case "/api/discard": { + if ( + typeof data.file !== "string" || + !data.file.trim() || + data.file.length > MAX_FILE_PATH_LENGTH || + !isValidFilePath(data.file) + ) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Invalid file path" })); + return; + } + if (pathname === "/api/stage") await gitService.stage(data.file); + else if (pathname === "/api/unstage") + await gitService.unstage(data.file); + else await gitService.discard(data.file); + if (broadcast) { + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + } + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + break; + } + case "/api/stageAll": + await gitService.stageAll(); + if (broadcast) { + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + } + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + break; + + case "/api/commit": { + if ( + typeof data.message !== "string" || + !data.message.trim() || + data.message.length > MAX_COMMIT_MESSAGE_LENGTH + ) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Invalid commit message" })); + return; + } + await gitService.commit(data.message); + if (broadcast) { + const changes = await gitService.getChanges(); + broadcast("changesUpdated", changes); + } + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + break; + } + case "/api/push": + await gitService.push(); + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + break; + + default: + res.writeHead(404); + res.end(JSON.stringify({ error: "Not found" })); + } + } catch (err) { + console.error( + "[TaskSync Remote] POST API error:", + getSafeErrorMessage(err), + ); + res.writeHead(500); + res.end(JSON.stringify({ error: "Operation failed" })); + } + } +} diff --git a/tasksync-chat/src/server/remoteServer.ts b/tasksync-chat/src/server/remoteServer.ts new file mode 100644 index 0000000..ff056be --- /dev/null +++ b/tasksync-chat/src/server/remoteServer.ts @@ -0,0 +1,650 @@ +import * as http from "http"; +import type * as https from "https"; +import * as path from "path"; +import * as vscode from "vscode"; +import { WebSocket, WebSocketServer } from "ws"; +import { + CONFIG_SECTION, + DEFAULT_REMOTE_CHAT_COMMAND, + DEFAULT_REMOTE_PORT, + DEFAULT_REMOTE_SESSION_QUERY, + ErrorCode, + isValidQueueId, + MAX_QUEUE_PROMPT_LENGTH, + MAX_RESPONSE_LENGTH, + RESPONSE_TIMEOUT_ALLOWED_VALUES, + WS_MAX_PAYLOAD, + WS_PROTOCOL_VERSION, +} from "../constants/remoteConstants"; +import type { TaskSyncWebviewProvider } from "../webview/webviewProvider"; +import { notifyQueueChanged } from "../webview/webviewUtils"; +import { GitService } from "./gitService"; +import { RemoteAuthService } from "./remoteAuthService"; +import { dispatchGitMessage } from "./remoteGitHandlers"; +import { RemoteHtmlService } from "./remoteHtmlService"; +import { dispatchSettingsMessage } from "./remoteSettingsHandler"; +import { + createServer, + findAvailablePort, + generateSelfSignedCert, + getLocalIp, + getSafeErrorMessage, + isOriginAllowed, + normalizeAttachments, + sendWsError, + type TlsCert, +} from "./serverUtils"; + +function getDebugEnabled(): boolean { + return vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get("remoteDebugLogging", false); +} +function debugLog(...args: unknown[]): void { + if (getDebugEnabled()) console.error("[TaskSync Remote Debug]", ...args); +} + +/** Get the configured VS Code command for opening chat from remote sessions. */ +function getRemoteChatCommand(): string { + return vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get("remoteChatCommand", DEFAULT_REMOTE_CHAT_COMMAND); +} + +/** Start a fresh chat session and send a query via Agent Mode. */ +async function openNewChatWithQuery(query: string): Promise { + await vscode.commands.executeCommand("workbench.action.chat.newChat"); + await vscode.commands.executeCommand(getRemoteChatCommand(), { query }); +} + +export interface RemoteServerUrls { + localUrl: string; + pin?: string; +} + +export class RemoteServer { + private server: http.Server | https.Server | null = null; + private wss: WebSocketServer | null = null; + private clients: Set = new Set(); + private authService: RemoteAuthService; + private htmlService: RemoteHtmlService; + private gitService: GitService; + private readonly RATE_LIMIT_WINDOW_MS = 1000; + private readonly RATE_LIMIT_MAX_MESSAGES = 50; + + private readonly RATE_LIMIT_MAX_PER_IP = 100; + private ipRateLimits: Map = + new Map(); + private readonly HEARTBEAT_INTERVAL_MS = 30000; + private readonly HEARTBEAT_TIMEOUT_MS = 45000; + private readonly MAX_CLIENTS = 50; + private running: boolean = false; + private port: number = DEFAULT_REMOTE_PORT; + private gitServiceAvailable: boolean = true; + private configChangeDisposable: vscode.Disposable | null = null; + private tlsCert: TlsCert | undefined; + + constructor( + private provider: TaskSyncWebviewProvider, + extensionUri: vscode.Uri, + context: vscode.ExtensionContext, + ) { + const webDir = path.join(extensionUri.fsPath, "web"); + const mediaDir = path.join(extensionUri.fsPath, "media"); + this.authService = new RemoteAuthService(context); + this.authService.onAuthFailure = (ip, count, lockedOut) => { + if (!lockedOut && count < 3) return; + const msg = lockedOut + ? `Client ${ip} locked out after ${count} failed PIN attempts.` + : `${count} failed PIN attempts from ${ip}.`; + vscode.window.showWarningMessage(`[TaskSync Remote] ${msg}`); + }; + this.htmlService = new RemoteHtmlService(webDir, mediaDir); + this.gitService = new GitService(); + } + + isRunning(): boolean { + return this.running; + } + getPort(): number { + return this.port; + } + + private get protocol(): string { + return this.tlsCert ? "https" : "http"; + } + private getRemoteState(): Record { + return { + ...(this.provider.getRemoteState() as Record), + }; + } + + getConnectionInfo(): { url: string; pin?: string } { + const url = `${this.protocol}://${getLocalIp()}:${this.port}`; + return { + url, + pin: this.authService.pinEnabled ? this.authService.pin : undefined, + }; + } + + async start(port: number = DEFAULT_REMOTE_PORT): Promise { + const pin = this.authService.pinEnabled ? this.authService.pin : undefined; + if (this.running) { + return { + localUrl: `${this.protocol}://${getLocalIp()}:${this.port}`, + pin, + }; + } + + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + this.authService.pinEnabled = config.get("remotePinEnabled", true); + + this.tlsCert = config.get("remoteTlsEnabled", false) + ? await generateSelfSignedCert(getLocalIp()) + : undefined; + this.htmlService.tlsEnabled = !!this.tlsCert; + + if (this.authService.pinEnabled) { + this.authService.pin = this.authService.getOrCreatePin(); + } + this.configChangeDisposable?.dispose(); + this.configChangeDisposable = vscode.workspace.onDidChangeConfiguration( + (e) => { + if (!e.affectsConfiguration(CONFIG_SECTION)) return; + const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION); + const wasPinEnabled = this.authService.pinEnabled; + this.authService.pinEnabled = cfg.get( + "remotePinEnabled", + true, + ); + if (this.authService.pinEnabled) { + const newPin = this.authService.getOrCreatePin(); + if (newPin !== this.authService.pin || !wasPinEnabled) { + this.authService.pin = newPin; + this.authService.authenticatedClients.clear(); + this.authService.clearSessionTokens(); + } + } + }, + ); + await this.initializeServices(); + this.port = await findAvailablePort(port); + + return new Promise((resolve, reject) => { + this.setupServerAndWss(); + + this.server!.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject(new Error(`Port ${this.port} is in use`)); + } else { + reject(err); + } + }); + + this.server!.listen(this.port, "0.0.0.0", () => { + this.markRunning(); + resolve({ + localUrl: `${this.protocol}://${getLocalIp()}:${this.port}`, + pin: this.authService.pinEnabled ? this.authService.pin : undefined, + }); + }); + }); + } + + stop(): void { + const shutdownMsg = JSON.stringify({ + type: "serverShutdown", + reason: "Server stopped by user", + }); + const targets = this.authService.pinEnabled + ? this.authService.authenticatedClients + : this.clients; + for (const ws of targets) { + if (ws.readyState === WebSocket.OPEN) { + try { + ws.send(shutdownMsg); + ws.close(1000, "Server shutdown"); + } catch { + // Ignore send errors during shutdown + } + } + } + this.wss?.close(); + this.server?.close(); + this.clients.clear(); + this.ipRateLimits.clear(); + if (this.ipRateLimitCleanupTimer) { + clearInterval(this.ipRateLimitCleanupTimer); + this.ipRateLimitCleanupTimer = null; + } + this.authService.cleanup(); + this.configChangeDisposable?.dispose(); + this.configChangeDisposable = null; + this.running = false; + } + + broadcast(type: string, data: unknown): void { + debugLog("broadcast:", type, JSON.stringify(data).slice(0, 100)); + const msg = JSON.stringify({ type, data }); + const targets = this.authService.pinEnabled + ? this.authService.authenticatedClients + : this.clients; + debugLog("broadcast: targets=", targets.size); + for (const ws of targets) { + if (ws.readyState === WebSocket.OPEN) { + if (ws.bufferedAmount > WS_MAX_PAYLOAD * 4) { + console.error("[TaskSync Remote] Skipping broadcast to slow client"); + continue; + } + try { + ws.send(msg); + } catch { + /* client may be closing */ + } + } + } + } + + private async initializeServices(): Promise { + this.gitServiceAvailable = true; + await this.gitService.initialize().catch((err: Error) => { + console.error( + "[TaskSync Remote] Git service failed to initialize:", + err.message, + ); + this.gitServiceAvailable = false; + }); + await this.htmlService.preloadTemplates(); + } + + private ipRateLimitCleanupTimer: ReturnType | null = null; + + private setupServerAndWss(): void { + const handler: http.RequestListener = (req, res) => + this.htmlService.handleHttp( + req, + res, + this.authService, + this.gitService, + this.gitServiceAvailable, + this.provider, + this.broadcast.bind(this), + ); + this.server = createServer(handler, this.tlsCert); + this.wss = new WebSocketServer({ + server: this.server, + maxPayload: WS_MAX_PAYLOAD, + }); + this.wss.on("connection", (ws, req) => this.handleConnection(ws, req)); + } + + private markRunning(): void { + this.running = true; + this.authService.startFailedAttemptsCleanup(); + + this.ipRateLimitCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [ip, limit] of this.ipRateLimits.entries()) { + if (now - limit.windowStart > 5 * 60 * 1000) { + this.ipRateLimits.delete(ip); + } + } + }, 60 * 1000); + } + + private handleConnection(ws: WebSocket, req: http.IncomingMessage): void { + debugLog( + `[TaskSync Remote] New WebSocket connection from ${req.socket.remoteAddress}, clients: ${this.clients.size + 1}`, + ); + if (this.clients.size >= this.MAX_CLIENTS) { + sendWsError(ws, "Server at capacity"); + ws.close(1013, "Server at capacity"); + return; + } + if (!isOriginAllowed(req)) { + sendWsError(ws, "Cross-origin connection blocked"); + ws.close(1008, "Origin not allowed"); + return; + } + + this.clients.add(ws); + let messageCount = 0; + let windowStart = Date.now(); + const clientIp = this.authService.normalizeIp( + req.socket.remoteAddress || "", + ); + let lastPongTime = Date.now(); + const heartbeatTimer = setInterval(() => { + if (Date.now() - lastPongTime > this.HEARTBEAT_TIMEOUT_MS) { + clearInterval(heartbeatTimer); + ws.terminate(); + return; + } + ws.ping(); + }, this.HEARTBEAT_INTERVAL_MS); + ws.on("pong", () => { + lastPongTime = Date.now(); + }); + + if (!this.authService.pinEnabled) { + this.authService.authenticatedClients.add(ws); + try { + ws.send( + JSON.stringify({ + type: "connected", + state: this.getRemoteState(), + gitServiceAvailable: this.gitServiceAvailable, + protocolVersion: WS_PROTOCOL_VERSION, + }), + ); + } catch (err) { + console.error( + "[TaskSync Remote] Error sending initial state:", + getSafeErrorMessage(err), + ); + try { + sendWsError(ws, "Internal server error"); + } catch { + /* socket closed */ + } + } + } else { + try { + ws.send(JSON.stringify({ type: "requireAuth" })); + } catch { + /* socket closed */ + } + } + + ws.on("message", (data) => { + const now = Date.now(); + if (now - windowStart > this.RATE_LIMIT_WINDOW_MS) { + windowStart = now; + messageCount = 0; + } + messageCount++; + if (messageCount > this.RATE_LIMIT_MAX_MESSAGES) { + sendWsError(ws, "Rate limit exceeded"); + return; + } + let ipLimit = this.ipRateLimits.get(clientIp); + if (!ipLimit || now - ipLimit.windowStart > this.RATE_LIMIT_WINDOW_MS) { + ipLimit = { count: 0, windowStart: now }; + this.ipRateLimits.set(clientIp, ipLimit); + } + ipLimit.count++; + if (ipLimit.count > this.RATE_LIMIT_MAX_PER_IP) { + sendWsError(ws, "Rate limit exceeded"); + return; + } + + const str = data.toString(); + if (!str) return; + try { + const msg = JSON.parse(str); + if (!msg || typeof msg.type !== "string") return; + void this.handleMessage(ws, clientIp, msg).catch((err) => { + console.error("[TaskSync Remote] handleMessage error:", err); + try { + sendWsError(ws, "Internal error"); + } catch { + /* closed */ + } + }); + } catch { + try { + sendWsError(ws, "Invalid JSON"); + } catch { + // Socket may be closed + } + } + }); + + ws.on("close", () => { + debugLog( + `[TaskSync Remote] WebSocket disconnected, remaining clients: ${this.clients.size - 1}`, + ); + clearInterval(heartbeatTimer); + this.clients.delete(ws); + this.authService.removeClient(ws); + }); + } + + private async handleMessage( + ws: WebSocket, + clientIp: string, + msg: { type: string; [key: string]: unknown }, + ): Promise { + if (!msg || typeof msg.type !== "string") { + sendWsError(ws, "Invalid message format"); + return; + } + debugLog("handleMessage:", msg.type, JSON.stringify(msg).slice(0, 200)); + + if (msg.type === "auth") { + debugLog("Processing auth from", clientIp); + this.authService.handleAuth( + ws, + clientIp, + typeof msg.pin === "string" ? msg.pin : undefined, + typeof msg.sessionToken === "string" ? msg.sessionToken : undefined, + () => this.getRemoteState(), + this.gitServiceAvailable, + ); + return; + } + + if ( + this.authService.pinEnabled && + !this.authService.authenticatedClients.has(ws) + ) { + debugLog("Rejected unauthenticated message:", msg.type); + sendWsError(ws, "Not authenticated"); + return; + } + + const broadcastFn = this.broadcast.bind(this); + + switch (msg.type) { + case "respond": { + const id = typeof msg.id === "string" ? msg.id : ""; + debugLog("respond: id=", id); + if (!id) { + sendWsError(ws, "Missing tool call ID", ErrorCode.INVALID_INPUT); + return; + } + const value = typeof msg.value === "string" ? msg.value : ""; + if (value.length > MAX_RESPONSE_LENGTH) { + sendWsError(ws, "Response too large", ErrorCode.INVALID_INPUT); + return; + } + const attachments = normalizeAttachments(msg.attachments); + const accepted = this.provider.resolveRemoteResponse( + id, + value, + attachments, + ); + debugLog("respond: accepted=", accepted); + if (!accepted) + sendWsError( + ws, + "This question was already answered from another device.", + ErrorCode.ALREADY_ANSWERED, + ); + break; + } + case "addToQueue": { + const prompt = typeof msg.prompt === "string" ? msg.prompt : ""; + if (!prompt || prompt.length > MAX_QUEUE_PROMPT_LENGTH) { + sendWsError(ws, "Invalid prompt length", ErrorCode.INVALID_INPUT); + return; + } + const attachments = normalizeAttachments(msg.attachments); + const result = this.provider.addToQueueFromRemote(prompt, attachments); + if (result.error) { + sendWsError(ws, result.error, result.code); + } + break; + } + case "removeFromQueue": { + const id = typeof msg.id === "string" ? msg.id : ""; + if (!isValidQueueId(id)) { + sendWsError(ws, "Invalid queue ID", ErrorCode.INVALID_INPUT); + return; + } + this.provider.removeFromQueueById(id); + break; + } + case "editQueuePrompt": { + const promptId = typeof msg.promptId === "string" ? msg.promptId : ""; + if (!isValidQueueId(promptId)) { + sendWsError(ws, "Invalid queue ID", ErrorCode.INVALID_INPUT); + return; + } + const newPrompt = + typeof msg.newPrompt === "string" ? msg.newPrompt : ""; + if (!newPrompt || newPrompt.length > MAX_QUEUE_PROMPT_LENGTH) { + sendWsError(ws, "Invalid prompt length", ErrorCode.INVALID_INPUT); + return; + } + const editResult = this.provider.editQueuePromptFromRemote( + promptId, + newPrompt, + ); + if (editResult.error) { + sendWsError(ws, editResult.error, editResult.code); + } + break; + } + case "reorderQueue": { + const fromIndex = Number(msg.fromIndex); + const toIndex = Number(msg.toIndex); + if ( + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex < 0 || + toIndex < 0 + ) { + sendWsError(ws, "Invalid indices", ErrorCode.INVALID_INPUT); + return; + } + this.provider.reorderQueueFromRemote(fromIndex, toIndex); + break; + } + case "toggleAutopilot": + await this.provider.setAutopilotEnabled(msg.enabled === true); + break; + case "toggleQueue": + this.provider.setQueueEnabled(msg.enabled === true); + break; + case "clearQueue": + this.provider.clearQueueFromRemote(); + break; + case "updateResponseTimeout": { + const timeout = Number(msg.timeout); + if (!RESPONSE_TIMEOUT_ALLOWED_VALUES.has(timeout)) { + sendWsError(ws, "Invalid timeout value", ErrorCode.INVALID_INPUT); + return; + } + await this.provider.setResponseTimeoutFromRemote(timeout); + break; + } + case "startSession": { + const rawPrompt = + typeof msg.prompt === "string" && msg.prompt.trim() + ? msg.prompt.slice(0, MAX_QUEUE_PROMPT_LENGTH) + : ""; + const prompt = rawPrompt + ? `The user is connected remotely via TaskSync and can ONLY see messages you send via the #askUser tool. Their request: "${rawPrompt}". Do the work, then call #askUser to report results. NEVER end your turn without calling #askUser.` + : DEFAULT_REMOTE_SESSION_QUERY; + debugLog( + "startSession:", + rawPrompt ? "custom prompt" : "default greeting", + "query length:", + prompt.length, + ); + // Route through configured chat command (defaults to Agent Mode) + void openNewChatWithQuery(prompt).catch((e) => + console.error("[TaskSync Remote] startSession:", e), + ); + break; + } + case "getState": { + const state = this.getRemoteState(); + debugLog( + "getState: isProcessing=", + state.isProcessing, + "pending=", + !!state.pending, + ); + ws.send( + JSON.stringify({ + type: "state", + data: state, + gitServiceAvailable: this.gitServiceAvailable, + }), + ); + break; + } + case "chatFollowUp": + case "chatMessage": { + const chatContent = + typeof msg.content === "string" ? msg.content.trim() : ""; + if (!chatContent) { + sendWsError(ws, "Empty message", ErrorCode.INVALID_INPUT); + break; + } + + // Send follow-up to configured chat mode with askUser context + const userMessage = chatContent.slice(0, MAX_QUEUE_PROMPT_LENGTH); + const fullQuery = `The remote user sent this follow-up via TaskSync: "${userMessage}". The user can ONLY see messages sent via the #askUser tool — your chat responses are invisible to them. Call #askUser to respond. NEVER end your turn without calling #askUser.`; + debugLog( + "chatMessage/chatFollowUp: sending to Agent Mode, length:", + fullQuery.length, + "content:", + userMessage.slice(0, 60), + ); + vscode.commands + .executeCommand(getRemoteChatCommand(), { + query: fullQuery, + }) + .then(undefined, (e: unknown) => console.error("[TaskSync Chat]", e)); + break; + } + case "chatCancel": + this.provider.cancelPendingToolCall("[Cancelled by user]"); + break; + case "newSession": { + this.provider.cancelPendingToolCall("[Session reset by user]"); + this.provider.startNewSession(); + const first = this.provider._promptQueue[0]; + const query = first?.prompt.slice(0, MAX_QUEUE_PROMPT_LENGTH); + if (first) { + this.provider._promptQueue.shift(); + notifyQueueChanged(this.provider); + } + const chatQuery = query + ? `The user is connected remotely via TaskSync and can ONLY see messages you send via the #askUser tool. Their request: "${query}". Do the work, then call #askUser to report results. NEVER end your turn without calling #askUser.` + : DEFAULT_REMOTE_SESSION_QUERY; + debugLog("newSession:", query ? "queue query" : "default greeting"); + void openNewChatWithQuery(chatQuery).catch((e) => + console.error("[TaskSync Remote] newSession error:", e), + ); + break; + } + default: { + const p = this.provider; + if (await dispatchSettingsMessage(ws, p, broadcastFn, msg)) break; + const handled = await dispatchGitMessage( + ws, + this.gitService, + this.gitServiceAvailable, + broadcastFn, + (q: string) => this.provider.searchFilesForRemote(q), + msg, + ); + if (!handled) debugLog("Unknown message type:", msg.type); + } + } + } +} diff --git a/tasksync-chat/src/server/remoteSettingsHandler.ts b/tasksync-chat/src/server/remoteSettingsHandler.ts new file mode 100644 index 0000000..4a7d581 --- /dev/null +++ b/tasksync-chat/src/server/remoteSettingsHandler.ts @@ -0,0 +1,213 @@ +/** + * Remote settings message dispatcher. + * Routes settings-related WebSocket messages to the provider's settings handlers. + */ +import type WebSocket from "ws"; +import { ErrorCode } from "../constants/remoteConstants"; +import * as settingsH from "../webview/settingsHandlers"; +import type { P } from "../webview/webviewTypes"; +import { sendWsError } from "./serverUtils"; + +/** + * Dispatch a settings message from a remote client. + * Returns true if the message was handled, false otherwise. + */ +export async function dispatchSettingsMessage( + ws: WebSocket, + provider: P, + broadcastFn: (type: string, data: unknown) => void, + msg: { type: string; [key: string]: unknown }, +): Promise { + switch (msg.type) { + case "updateSoundSetting": + await settingsH.handleUpdateSoundSetting(provider, msg.enabled === true); + broadcastSettingsChanged(provider, broadcastFn); + return true; + + case "updateInteractiveApprovalSetting": + await settingsH.handleUpdateInteractiveApprovalSetting( + provider, + msg.enabled === true, + ); + broadcastSettingsChanged(provider, broadcastFn); + return true; + + case "updateSendWithCtrlEnterSetting": + await settingsH.handleUpdateSendWithCtrlEnterSetting( + provider, + msg.enabled === true, + ); + broadcastSettingsChanged(provider, broadcastFn); + return true; + + case "updateHumanDelaySetting": + await settingsH.handleUpdateHumanDelaySetting( + provider, + msg.enabled === true, + ); + broadcastSettingsChanged(provider, broadcastFn); + return true; + + case "updateHumanDelayMin": { + const val = Number(msg.value); + if (!Number.isFinite(val)) { + sendWsError(ws, "Invalid value", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleUpdateHumanDelayMin(provider, val); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "updateHumanDelayMax": { + const val = Number(msg.value); + if (!Number.isFinite(val)) { + sendWsError(ws, "Invalid value", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleUpdateHumanDelayMax(provider, val); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "updateSessionWarningHours": { + const val = Number(msg.value); + if (!Number.isFinite(val)) { + sendWsError(ws, "Invalid value", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleUpdateSessionWarningHours(provider, val); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "updateMaxConsecutiveAutoResponses": { + const val = Number(msg.value); + if (!Number.isFinite(val)) { + sendWsError(ws, "Invalid value", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleUpdateMaxConsecutiveAutoResponses(provider, val); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "updateAutopilotText": { + const text = typeof msg.text === "string" ? msg.text : ""; + await settingsH.handleUpdateAutopilotText(provider, text); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "addAutopilotPrompt": { + const prompt = typeof msg.prompt === "string" ? msg.prompt : ""; + if (!prompt.trim()) { + sendWsError(ws, "Empty prompt", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleAddAutopilotPrompt(provider, prompt); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "editAutopilotPrompt": { + const index = Number(msg.index); + const prompt = typeof msg.prompt === "string" ? msg.prompt : ""; + if (!Number.isFinite(index) || !prompt.trim()) { + sendWsError(ws, "Invalid input", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleEditAutopilotPrompt(provider, index, prompt); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "removeAutopilotPrompt": { + const index = Number(msg.index); + if (!Number.isFinite(index)) { + sendWsError(ws, "Invalid index", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleRemoveAutopilotPrompt(provider, index); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "reorderAutopilotPrompts": { + const from = Number(msg.fromIndex); + const to = Number(msg.toIndex); + if (!Number.isFinite(from) || !Number.isFinite(to)) { + sendWsError(ws, "Invalid indices", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleReorderAutopilotPrompts(provider, from, to); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "addReusablePrompt": { + const name = typeof msg.name === "string" ? msg.name : ""; + const prompt = typeof msg.prompt === "string" ? msg.prompt : ""; + if (!name.trim() || !prompt.trim()) { + sendWsError(ws, "Name and prompt required", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleAddReusablePrompt(provider, name, prompt); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "editReusablePrompt": { + const id = typeof msg.id === "string" ? msg.id : ""; + const name = typeof msg.name === "string" ? msg.name : ""; + const prompt = typeof msg.prompt === "string" ? msg.prompt : ""; + if (!id || !name.trim() || !prompt.trim()) { + sendWsError(ws, "Invalid input", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleEditReusablePrompt(provider, id, name, prompt); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "removeReusablePrompt": { + const id = typeof msg.id === "string" ? msg.id : ""; + if (!id) { + sendWsError(ws, "Missing prompt ID", ErrorCode.INVALID_INPUT); + return true; + } + await settingsH.handleRemoveReusablePrompt(provider, id); + broadcastSettingsChanged(provider, broadcastFn); + return true; + } + + case "searchSlashCommands": { + const query = typeof msg.query === "string" ? msg.query : ""; + const queryLower = query.toLowerCase(); + const results = provider._reusablePrompts.filter( + (rp: { name: string; prompt: string }) => + rp.name.toLowerCase().includes(queryLower) || + rp.prompt.toLowerCase().includes(queryLower), + ); + try { + ws.send( + JSON.stringify({ type: "slashCommandResults", prompts: results }), + ); + } catch { + /* ignore */ + } + return true; + } + + default: + return false; + } +} + +/** Broadcast all current settings to remote clients after a change. Uses SSOT payload builder. */ +function broadcastSettingsChanged( + provider: P, + broadcastFn: (type: string, data: unknown) => void, +): void { + broadcastFn("settingsChanged", settingsH.buildSettingsPayload(provider)); +} diff --git a/tasksync-chat/src/server/serverUtils.test.ts b/tasksync-chat/src/server/serverUtils.test.ts new file mode 100644 index 0000000..484486c --- /dev/null +++ b/tasksync-chat/src/server/serverUtils.test.ts @@ -0,0 +1,419 @@ +import * as http from "http"; +import * as https from "https"; +import { describe, expect, it, vi } from "vitest"; +import { + createServer, + findAvailablePort, + generateSelfSignedCert, + getLocalIp, + getSafeErrorMessage, + isOriginAllowed, + isPortAvailable, + normalizeAttachments, + sendWsError, + setSecurityHeaders, +} from "../server/serverUtils"; + +// ─── getSafeErrorMessage ───────────────────────────────────── + +describe("getSafeErrorMessage", () => { + it("extracts message from Error instances", () => { + expect(getSafeErrorMessage(new Error("disk full"))).toBe("disk full"); + }); + + it("extracts message from Error subclasses", () => { + expect(getSafeErrorMessage(new TypeError("bad type"))).toBe("bad type"); + expect(getSafeErrorMessage(new RangeError("out of range"))).toBe( + "out of range", + ); + }); + + it("returns fallback for non-Error values", () => { + expect(getSafeErrorMessage("string error")).toBe( + "An unexpected error occurred", + ); + expect(getSafeErrorMessage(42)).toBe("An unexpected error occurred"); + expect(getSafeErrorMessage(null)).toBe("An unexpected error occurred"); + expect(getSafeErrorMessage(undefined)).toBe("An unexpected error occurred"); + expect(getSafeErrorMessage({ message: "fake" })).toBe( + "An unexpected error occurred", + ); + }); + + it("uses custom fallback when provided", () => { + expect(getSafeErrorMessage("oops", "Custom fallback")).toBe( + "Custom fallback", + ); + }); +}); + +// ─── setSecurityHeaders ────────────────────────────────────── + +describe("setSecurityHeaders", () => { + function createMockResponse() { + const headers: Record = {}; + return { + setHeader: (name: string, value: string) => { + headers[name] = value; + }, + headers, + } as unknown as http.ServerResponse & { headers: Record }; + } + + it("sets standard security headers", () => { + const res = createMockResponse(); + setSecurityHeaders(res); + expect(res.headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(res.headers["X-Frame-Options"]).toBe("DENY"); + expect(res.headers["Referrer-Policy"]).toBe("no-referrer"); + }); + + it("does NOT set HSTS when isTls is false/undefined", () => { + const res = createMockResponse(); + setSecurityHeaders(res); + expect(res.headers["Strict-Transport-Security"]).toBeUndefined(); + + const res2 = createMockResponse(); + setSecurityHeaders(res2, false); + expect(res2.headers["Strict-Transport-Security"]).toBeUndefined(); + }); + + it("sets HSTS when isTls is true", () => { + const res = createMockResponse(); + setSecurityHeaders(res, true); + expect(res.headers["Strict-Transport-Security"]).toBe("max-age=31536000"); + }); +}); + +// ─── isOriginAllowed ───────────────────────────────────────── + +describe("isOriginAllowed", () => { + function createMockRequest( + origin: string | undefined, + host: string, + ): http.IncomingMessage { + return { + headers: { + ...(origin !== undefined ? { origin } : {}), + host, + }, + socket: { remoteAddress: "127.0.0.1" }, + method: "GET", + url: "/", + } as unknown as http.IncomingMessage; + } + + it("allows requests without Origin header", () => { + const req = createMockRequest(undefined, "localhost:3580"); + expect(isOriginAllowed(req)).toBe(true); + }); + + it("allows same-origin requests", () => { + const req = createMockRequest("http://localhost:3580", "localhost:3580"); + expect(isOriginAllowed(req)).toBe(true); + }); + + it("allows same host with https", () => { + const req = createMockRequest("https://myhost:3580", "myhost:3580"); + expect(isOriginAllowed(req)).toBe(true); + }); + + it("rejects cross-origin requests", () => { + const req = createMockRequest("http://evil.com", "localhost:3580"); + expect(isOriginAllowed(req)).toBe(false); + }); + + it("rejects when origin host differs from request host", () => { + const req = createMockRequest("http://localhost:9999", "localhost:3580"); + expect(isOriginAllowed(req)).toBe(false); + }); + + it("rejects malformed origin URLs", () => { + const req = createMockRequest("not-a-url", "localhost:3580"); + expect(isOriginAllowed(req)).toBe(false); + }); +}); + +// ─── normalizeAttachments ──────────────────────────────────── + +describe("normalizeAttachments", () => { + it("returns empty array for non-array input", () => { + expect(normalizeAttachments(null)).toEqual([]); + expect(normalizeAttachments(undefined)).toEqual([]); + expect(normalizeAttachments("string")).toEqual([]); + expect(normalizeAttachments(123)).toEqual([]); + expect(normalizeAttachments({})).toEqual([]); + }); + + it("returns empty array for empty array", () => { + expect(normalizeAttachments([])).toEqual([]); + }); + + it("normalizes valid attachments", () => { + const input = [ + { id: "att1", name: "file.png", uri: "file:///path/to/file.png" }, + ]; + const result = normalizeAttachments(input); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: "att1", + name: "file.png", + uri: "file:///path/to/file.png", + }); + }); + + it("generates UUID for missing/invalid IDs", () => { + const input = [{ name: "file.png", uri: "file:///path" }]; + const result = normalizeAttachments(input); + expect(result).toHaveLength(1); + expect(result[0].id).toBeTruthy(); + expect(result[0].id.length).toBeGreaterThan(0); + }); + + it("generates UUID for empty string ID", () => { + const input = [{ id: "", name: "file.png", uri: "file:///path" }]; + const result = normalizeAttachments(input); + expect(result[0].id).not.toBe(""); + }); + + it("skips items without string uri", () => { + const input = [ + { id: "a", name: "file.png", uri: 123 }, + { id: "b", name: "file.png" }, // missing uri + ]; + expect(normalizeAttachments(input)).toEqual([]); + }); + + it("skips items without string name", () => { + const input = [{ id: "a", name: 123, uri: "file:///path" }]; + expect(normalizeAttachments(input)).toEqual([]); + }); + + it("skips null/undefined items", () => { + const input = [null, undefined, { id: "a", name: "f", uri: "u" }]; + const result = normalizeAttachments(input); + expect(result).toHaveLength(1); + }); + + it("enforces MAX_ATTACHMENTS limit", () => { + const input = Array.from({ length: 30 }, (_, i) => ({ + id: `att_${i}`, + name: `file${i}.txt`, + uri: `file:///path/${i}`, + })); + const result = normalizeAttachments(input); + expect(result.length).toBeLessThanOrEqual(20); // MAX_ATTACHMENTS = 20 + }); + + it("rejects URIs exceeding MAX_ATTACHMENT_URI_LENGTH", () => { + const input = [ + { + id: "a", + name: "f.txt", + uri: "x".repeat(1001), // MAX_ATTACHMENT_URI_LENGTH = 1000 + }, + ]; + expect(normalizeAttachments(input)).toEqual([]); + }); + + it("rejects names exceeding MAX_ATTACHMENT_NAME_LENGTH", () => { + const input = [ + { + id: "a", + name: "x".repeat(256), // MAX_ATTACHMENT_NAME_LENGTH = 255 + uri: "file:///path", + }, + ]; + expect(normalizeAttachments(input)).toEqual([]); + }); + + it("accepts ID up to 128 chars", () => { + const input = [ + { + id: "x".repeat(128), + name: "f.txt", + uri: "file:///p", + }, + ]; + const result = normalizeAttachments(input); + expect(result[0].id).toBe("x".repeat(128)); + }); + + it("generates UUID for ID exceeding 128 chars", () => { + const input = [ + { + id: "x".repeat(129), + name: "f.txt", + uri: "file:///p", + }, + ]; + const result = normalizeAttachments(input); + expect(result[0].id).not.toBe("x".repeat(129)); + }); +}); + +// ─── sendWsError ───────────────────────────────────────────── + +describe("sendWsError", () => { + function createMockWs() { + const sent: string[] = []; + return { + ws: { + send: vi.fn((data: string) => { + sent.push(data); + }), + } as any, + sent, + }; + } + + it("sends error message without code", () => { + const { ws, sent } = createMockWs(); + sendWsError(ws, "Something went wrong"); + expect(sent).toHaveLength(1); + const parsed = JSON.parse(sent[0]); + expect(parsed).toEqual({ type: "error", message: "Something went wrong" }); + expect(parsed.code).toBeUndefined(); + }); + + it("sends error message with code", () => { + const { ws, sent } = createMockWs(); + sendWsError(ws, "Auth failed", "AUTH_ERROR"); + const parsed = JSON.parse(sent[0]); + expect(parsed).toEqual({ + type: "error", + code: "AUTH_ERROR", + message: "Auth failed", + }); + }); + + it("silently handles send failures", () => { + const ws = { + send: vi.fn(() => { + throw new Error("Socket closed"); + }), + } as any; + // Should not throw + expect(() => sendWsError(ws, "test")).not.toThrow(); + }); +}); + +// ─── getLocalIp ────────────────────────────────────────────── + +describe("getLocalIp", () => { + it("returns a string IP address", () => { + const ip = getLocalIp(); + expect(typeof ip).toBe("string"); + expect(ip.length).toBeGreaterThan(0); + }); + + it("returns a valid IPv4 format", () => { + const ip = getLocalIp(); + // Should be either a proper IPv4 or 127.0.0.1 fallback + expect(ip).toMatch(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/); + }); +}); + +// ─── createServer ──────────────────────────────────────────── + +describe("createServer", () => { + it("creates an HTTP server when no TLS config", () => { + const handler = vi.fn(); + const server = createServer(handler); + expect(server).toBeInstanceOf(http.Server); + server.close(); + }); + + it("creates an HTTPS server when TLS config provided", async () => { + const handler = vi.fn(); + const tls = await generateSelfSignedCert("127.0.0.1"); + const server = createServer(handler, tls); + expect(server).toBeInstanceOf(https.Server); + server.close(); + }); +}); + +// ─── isPortAvailable ───────────────────────────────────────── + +describe("isPortAvailable", () => { + it("returns true for an available port", async () => { + // Use port 0 to let OS pick a free port, then check its actual port + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const boundPort = (server.address() as { port: number }).port; + server.close(); + // Wait for close then check the port + await new Promise((resolve) => server.on("close", resolve)); + const available = await isPortAvailable(boundPort); + expect(available).toBe(true); + }); + + it("returns false for a port in use", async () => { + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const boundPort = (server.address() as { port: number }).port; + try { + const available = await isPortAvailable(boundPort); + expect(available).toBe(false); + } finally { + server.close(); + } + }); +}); + +// ─── findAvailablePort ─────────────────────────────────────── + +describe("findAvailablePort", () => { + it("finds an available port starting from a given port", async () => { + const port = await findAvailablePort(49152); + expect(typeof port).toBe("number"); + expect(port).toBeGreaterThanOrEqual(49152); + expect(port).toBeLessThan(49162); // within 10-port range + }); + + it("skips occupied ports and returns next available", async () => { + // Bind a port, then search starting from it + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const boundPort = (server.address() as { port: number }).port; + try { + const port = await findAvailablePort(boundPort); + expect(port).toBeGreaterThan(boundPort); + } finally { + server.close(); + } + }); + + it("throws when all 10 ports are occupied", async () => { + // Bind 10 consecutive ports + const servers: http.Server[] = []; + const baseServer = http.createServer(); + await new Promise((resolve) => baseServer.listen(0, resolve)); + const basePort = (baseServer.address() as { port: number }).port; + servers.push(baseServer); + for (let i = 1; i < 10; i++) { + const s = http.createServer(); + await new Promise((resolve) => s.listen(basePort + i, resolve)); + servers.push(s); + } + try { + await expect(findAvailablePort(basePort)).rejects.toThrow( + /No available ports/, + ); + } finally { + for (const s of servers) s.close(); + } + }); +}); + +// ─── generateSelfSignedCert ────────────────────────────────── + +describe("generateSelfSignedCert", () => { + it("generates a TLS cert with key and cert strings", async () => { + const result = await generateSelfSignedCert("127.0.0.1"); + expect(typeof result.key).toBe("string"); + expect(typeof result.cert).toBe("string"); + expect(result.key).toContain("PRIVATE KEY"); + expect(result.cert).toContain("CERTIFICATE"); + }); +}); diff --git a/tasksync-chat/src/server/serverUtils.ts b/tasksync-chat/src/server/serverUtils.ts new file mode 100644 index 0000000..c89fc7a --- /dev/null +++ b/tasksync-chat/src/server/serverUtils.ts @@ -0,0 +1,195 @@ +import * as crypto from "crypto"; +import * as http from "http"; +import * as https from "https"; +import * as os from "os"; +import selfsigned from "selfsigned"; +import type { WebSocket } from "ws"; +import { + MAX_ATTACHMENT_NAME_LENGTH, + MAX_ATTACHMENT_URI_LENGTH, + MAX_ATTACHMENTS, +} from "../constants/remoteConstants"; +import type { AttachmentInfo } from "../webview/webviewProvider"; + +/** + * Send a typed error response over a WebSocket connection. + * + * Note: WS errors use `{ type: "error", message, code? }` shape. + * HTTP API errors use `{ error: "message" }` shape (see remoteHtmlService.ts). + * Both shapes are handled by the adapter.js client. + */ +export function sendWsError( + ws: WebSocket, + message: string, + code?: string, +): void { + try { + ws.send( + JSON.stringify( + code ? { type: "error", code, message } : { type: "error", message }, + ), + ); + } catch { + // Socket may be closing/closed + } +} + +/** + * Safely extract an error message from an unknown thrown value. + * Returns a generic message for non-Error values to avoid leaking internals. + */ +export function getSafeErrorMessage( + err: unknown, + fallback = "An unexpected error occurred", +): string { + if (err instanceof Error) return err.message; + return fallback; +} + +/** + * Set standard security headers on HTTP responses. + * Prevents MIME sniffing, clickjacking, and Referer-based PIN leakage. + * When `isTls` is true, adds HSTS header to enforce HTTPS. + */ +export function setSecurityHeaders( + res: http.ServerResponse, + isTls?: boolean, +): void { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "no-referrer"); + if (isTls) { + res.setHeader("Strict-Transport-Security", "max-age=31536000"); + } +} + +/** + * Validate that the Origin header matches the request Host. + * Returns true if origin is allowed (or missing), false if cross-origin. + * + * Design: Missing Origin header is intentionally allowed because: + * 1. Browsers always send Origin on cross-origin requests — this check blocks cross-site attacks. + * 2. Non-browser clients (curl, scripts) omit Origin but must still authenticate via PIN. + * This is a defense-in-depth layer, not the primary auth mechanism. + */ +export function isOriginAllowed(req: http.IncomingMessage): boolean { + const origin = req.headers.origin; + if (!origin) { + // Non-browser client or same-origin — Origin header absent, allow through + return true; + } + try { + const originHost = new URL(origin).host; + const requestHost = req.headers.host || ""; + return originHost === requestHost; + } catch { + return false; + } +} + +/** + * Normalize and validate attachment arrays from remote messages. + * Generates UUIDs for missing/invalid attachment IDs. + */ +export function normalizeAttachments(raw: unknown): AttachmentInfo[] { + if (!Array.isArray(raw)) return []; + const attachments: AttachmentInfo[] = []; + for ( + let idx = 0; + idx < raw.length && attachments.length < MAX_ATTACHMENTS; + idx++ + ) { + const a = raw[idx]; + if (!a || typeof a !== "object") continue; + if (typeof a.uri !== "string" || a.uri.length > MAX_ATTACHMENT_URI_LENGTH) + continue; + if ( + typeof a.name !== "string" || + a.name.length > MAX_ATTACHMENT_NAME_LENGTH + ) + continue; + // Use client-provided ID only if it's a reasonable string; otherwise generate a UUID + const id = + typeof a.id === "string" && a.id.length > 0 && a.id.length <= 128 + ? a.id + : crypto.randomUUID(); + attachments.push({ id, name: a.name, uri: a.uri }); + } + return attachments; +} + +/** Get the preferred local IPv4 address, favouring physical interfaces. */ +export function getLocalIp(): string { + const nets = os.networkInterfaces(); + const preferredPrefixes = ["en", "eth", "wlan", "Wi-Fi", "Ethernet"]; + let fallback: string | null = null; + for (const name of Object.keys(nets)) { + for (const net of nets[name] || []) { + if (net.family === "IPv4" && !net.internal) { + if (preferredPrefixes.some((p) => name.startsWith(p))) + return net.address; + if (!fallback) fallback = net.address; + } + } + } + return fallback || "127.0.0.1"; +} + +/** Check whether a TCP port is available for binding. */ +export function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = http.createServer(); + server.once("error", () => { + server.close(); + resolve(false); + }); + server.once("listening", () => server.close(() => resolve(true))); + server.listen(port, "0.0.0.0"); + }); +} + +/** Find the first available port starting from `startPort`, checking up to 10 sequential ports. */ +export async function findAvailablePort(startPort: number): Promise { + for (let p = startPort; p < startPort + 10; p++) { + if (await isPortAvailable(p)) return p; + } + throw new Error(`No available ports in range ${startPort}-${startPort + 9}`); +} + +/** TLS certificate pair for self-signed HTTPS. */ +export interface TlsCert { + key: string; + cert: string; +} + +/** + * Generate a self-signed TLS certificate valid for the given hostname/IP. + * Uses the `selfsigned` package (Node.js built-in crypto backend). + */ +export async function generateSelfSignedCert(host: string): Promise { + const attrs = [{ name: "commonName", value: host }]; + const notBefore = new Date(); + const notAfter = new Date(); + notAfter.setFullYear(notAfter.getFullYear() + 1); + const pems = await selfsigned.generate(attrs, { + keySize: 2048, + notBeforeDate: notBefore, + notAfterDate: notAfter, + extensions: [{ name: "subjectAltName", altNames: [{ type: 7, ip: host }] }], + }); + return { key: pems.private, cert: pems.cert }; +} + +/** + * Create an HTTP or HTTPS server based on TLS configuration. + * When `tls` is provided, creates an HTTPS server; otherwise plain HTTP. + */ +export function createServer( + handler: http.RequestListener, + tls?: TlsCert, +): http.Server | https.Server { + if (tls) { + return https.createServer({ key: tls.key, cert: tls.cert }, handler); + } + return http.createServer(handler); +} diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index 2112a19..fc84d07 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -1,213 +1,282 @@ -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { TaskSyncWebviewProvider } from './webview/webviewProvider'; -import { getImageMimeType } from './utils/imageUtils'; - -export interface Input { - question: string; -} - -export interface AskUserToolResult { - response: string; - attachments: string[]; - queue: boolean; -} - -/** - * Reads a file as Uint8Array for efficient binary handling - */ -async function readFileAsBuffer(filePath: string): Promise { - const buffer = await fs.promises.readFile(filePath); - return new Uint8Array(buffer); -} - -/** - * Creates a cancellation promise with proper cleanup to prevent memory leaks. - * Returns both the promise and a dispose function to clean up the event listener. - */ -function createCancellationPromise(token: vscode.CancellationToken): { - promise: Promise; - dispose: () => void; -} { - let disposable: vscode.Disposable | undefined; - - const promise = new Promise((_, reject) => { - if (token.isCancellationRequested) { - reject(new vscode.CancellationError()); - return; - } - disposable = token.onCancellationRequested(() => { - reject(new vscode.CancellationError()); - }); - }); - - return { - promise, - dispose: () => disposable?.dispose() - }; -} - -/** - * Core logic to ask user, reusable by MCP server - * Queue handling and history tracking is done in waitForUserResponse() - */ -export async function askUser( - params: Input, - provider: TaskSyncWebviewProvider, - token: vscode.CancellationToken -): Promise { - // Check if already cancelled before starting - if (token.isCancellationRequested) { - throw new vscode.CancellationError(); - } - - // Create cancellation promise with cleanup capability - const cancellation = createCancellationPromise(token); - - try { - // Race the user response against cancellation - const result = await Promise.race([ - provider.waitForUserResponse(params.question), - cancellation.promise - ]); - - // Handle case where request was superseded by another call - if (result.cancelled) { - return { - response: result.value, - attachments: [], - queue: result.queue - }; - } - - let responseText = result.value; - const validAttachments: string[] = []; - - // Process attachments to resolve context content - if (result.attachments && result.attachments.length > 0) { - for (const att of result.attachments) { - if (att.uri.startsWith('context://')) { - // Start of context content - responseText += `\n\n[Attached Context: ${att.name}]\n`; - - const content = await provider.resolveContextContent(att.uri); - if (content) { - responseText += content; - } else { - responseText += '(Context content not available)'; - } - - // End of context content - responseText += '\n[End of Context]\n'; - } else { - // Regular file attachment - validAttachments.push(att.uri); - } - } - } - - return { - response: responseText, - attachments: validAttachments, - queue: result.queue - }; - } catch (error) { - // Re-throw cancellation errors without logging (they're expected) - if (error instanceof vscode.CancellationError) { - throw error; - } - // Log other errors - console.error('[TaskSync] askUser error:', error instanceof Error ? error.message : error); - // Show error to user so they know something went wrong - vscode.window.showErrorMessage(`TaskSync: ${error instanceof Error ? error.message : 'Failed to show question'}`); - return { - response: '', - attachments: [], - queue: false - }; - } finally { - // Always clean up the cancellation listener to prevent memory leaks - cancellation.dispose(); - } -} - -export function registerTools(context: vscode.ExtensionContext, provider: TaskSyncWebviewProvider) { - - // Register ask_user tool (VS Code native LM tool) - const askUserTool = vscode.lm.registerTool('ask_user', { - prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions) { - const rawQuestion = typeof options?.input?.question === 'string' ? options.input.question : ''; - const questionPreview = rawQuestion.trim().replace(/\s+/g, ' '); - - const MAX_PREVIEW_LEN = 40; - const truncated = questionPreview.length > MAX_PREVIEW_LEN - ? questionPreview.slice(0, MAX_PREVIEW_LEN - 3) + '...' - : questionPreview; - - return { - invocationMessage: truncated ? `ask_user: ${truncated}` : 'ask_user' - }; - }, - async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { - const params = options.input; - - try { - const result = await askUser(params, provider, token); - - // Build result parts - text first, then images - const resultParts: (vscode.LanguageModelTextPart | vscode.LanguageModelDataPart)[] = [ - new vscode.LanguageModelTextPart(JSON.stringify({ - response: result.response, - queued: result.queue, - attachmentCount: result.attachments.length - })) - ]; - - // Add image attachments as LanguageModelDataPart for vision models - if (result.attachments && result.attachments.length > 0) { - const imagePromises = result.attachments.map(async (uri) => { - try { - const fileUri = vscode.Uri.parse(uri); - const filePath = fileUri.fsPath; - - // Check if file exists - if (!fs.existsSync(filePath)) { - console.error('[TaskSync] Attachment file does not exist:', filePath); - return null; - } - - const mimeType = getImageMimeType(filePath); - - // Only process image files (skip non-image attachments) - if (mimeType !== 'application/octet-stream') { - const data = await readFileAsBuffer(filePath); - const dataPart = vscode.LanguageModelDataPart.image(data, mimeType); - return dataPart; - } - return null; - } catch (error) { - console.error('[TaskSync] Failed to read image attachment:', error); - return null; - } - }); - - const imageParts = await Promise.all(imagePromises); - for (const part of imageParts) { - if (part !== null) { - resultParts.push(part); - } - } - } - - return new vscode.LanguageModelToolResult(resultParts); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return new vscode.LanguageModelToolResult([ - new vscode.LanguageModelTextPart("Error: " + message) - ]); - } - } - }); - - context.subscriptions.push(askUserTool); -} +import * as fs from "fs"; +import * as vscode from "vscode"; +import { getImageMimeType } from "./utils/imageUtils"; +import { TaskSyncWebviewProvider } from "./webview/webviewProvider"; +import { debugLog } from "./webview/webviewUtils"; + +export interface Input { + question: string; + summary?: string; +} + +export interface AskUserToolResult { + response: string; + attachments: string[]; + queue: boolean; +} + +/** + * Reads a file as Uint8Array for efficient binary handling + */ +async function readFileAsBuffer(filePath: string): Promise { + const buffer = await fs.promises.readFile(filePath); + return new Uint8Array(buffer); +} + +/** + * Creates a cancellation promise with proper cleanup to prevent memory leaks. + * Returns both the promise and a dispose function to clean up the event listener. + */ +function createCancellationPromise(token: vscode.CancellationToken): { + promise: Promise; + dispose: () => void; +} { + let disposable: vscode.Disposable | undefined; + + const promise = new Promise((_, reject) => { + if (token.isCancellationRequested) { + reject(new vscode.CancellationError()); + return; + } + disposable = token.onCancellationRequested(() => { + reject(new vscode.CancellationError()); + }); + }); + + return { + promise, + dispose: () => disposable?.dispose(), + }; +} + +/** + * Core logic to ask user, reusable by MCP server + * Queue handling and history tracking is done in waitForUserResponse() + */ +export async function askUser( + params: Input, + provider: TaskSyncWebviewProvider, + token: vscode.CancellationToken, +): Promise { + debugLog( + "[TaskSync] askUser invoked — question:", + params.question.slice(0, 80), + params.summary + ? `summary: ${params.summary.slice(0, 60)}...` + : "(no summary)", + ); + // Check if already cancelled before starting + if (token.isCancellationRequested) { + debugLog("[TaskSync] askUser — already cancelled before starting"); + throw new vscode.CancellationError(); + } + + // Create cancellation promise with cleanup capability + const cancellation = createCancellationPromise(token); + + try { + // Race the user response against cancellation + const result = await Promise.race([ + provider.waitForUserResponse(params.question, params.summary), + cancellation.promise, + ]); + + // Handle case where request was superseded by another call + if (result.cancelled) { + debugLog( + "[TaskSync] askUser — superseded/cancelled, response:", + result.value.slice(0, 80), + ); + return { + response: result.value, + attachments: [], + queue: result.queue, + }; + } + debugLog( + "[TaskSync] askUser — user responded:", + result.value.slice(0, 80), + "attachments:", + result.attachments?.length ?? 0, + ); + + let responseText = result.value; + const validAttachments: string[] = []; + + // Process attachments to resolve context content + if (result.attachments && result.attachments.length > 0) { + for (const att of result.attachments) { + if (att.uri.startsWith("context://")) { + // Start of context content + responseText += `\n\n[Attached Context: ${att.name}]\n`; + + const content = await provider.resolveContextContent(att.uri); + if (content) { + responseText += content; + } else { + responseText += "(Context content not available)"; + } + + // End of context content + responseText += "\n[End of Context]\n"; + } else { + // Regular file attachment + validAttachments.push(att.uri); + } + } + } + + debugLog( + "[TaskSync] askUser — returning result to AI (response length:", + responseText.length, + ", attachments:", + validAttachments.length, + ") — AI should call askUser again next", + ); + return { + response: responseText, + attachments: validAttachments, + queue: result.queue, + }; + } catch (error) { + // Re-throw cancellation errors without logging (they're expected) + if (error instanceof vscode.CancellationError) { + throw error; + } + // Log other errors + console.error( + "[TaskSync] askUser error:", + error instanceof Error ? error.message : error, + ); + // Show error to user so they know something went wrong + vscode.window.showErrorMessage( + `TaskSync: ${error instanceof Error ? error.message : "Failed to show question"}`, + ); + return { + response: "", + attachments: [], + queue: false, + }; + } finally { + // Always clean up the cancellation listener to prevent memory leaks + cancellation.dispose(); + } +} + +export function registerTools( + context: vscode.ExtensionContext, + provider: TaskSyncWebviewProvider, +): void { + // Register ask_user tool (VS Code native LM tool) + const askUserTool = vscode.lm.registerTool("ask_user", { + prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions, + ) { + const rawQuestion = + typeof options?.input?.question === "string" + ? options.input.question + : ""; + const questionPreview = rawQuestion.trim().replace(/\s+/g, " "); + + const MAX_PREVIEW_LEN = 40; + const truncated = + questionPreview.length > MAX_PREVIEW_LEN + ? questionPreview.slice(0, MAX_PREVIEW_LEN - 3) + "..." + : questionPreview; + + return { + invocationMessage: truncated ? `ask_user: ${truncated}` : "ask_user", + }; + }, + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken, + ) { + const params = options.input; + + debugLog( + "[TaskSync] LM tool invoke — question:", + params.question.slice(0, 60), + ); + try { + const result = await askUser(params, provider, token); + + // Build result parts - text first, then images + const resultParts: ( + | vscode.LanguageModelTextPart + | vscode.LanguageModelDataPart + )[] = [ + new vscode.LanguageModelTextPart( + JSON.stringify({ + response: result.response, + queued: result.queue, + attachmentCount: result.attachments.length, + instruction: + "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", + }), + ), + ]; + + // Add image attachments as LanguageModelDataPart for vision models + if (result.attachments && result.attachments.length > 0) { + const imagePromises = result.attachments.map(async (uri) => { + try { + const fileUri = vscode.Uri.parse(uri); + const filePath = fileUri.fsPath; + + // Check if file exists + if (!fs.existsSync(filePath)) { + console.error( + "[TaskSync] Attachment file does not exist:", + filePath, + ); + return null; + } + + const mimeType = getImageMimeType(filePath); + + // Only process image files (skip non-image attachments) + if (mimeType !== "application/octet-stream") { + const data = await readFileAsBuffer(filePath); + const dataPart = vscode.LanguageModelDataPart.image( + data, + mimeType, + ); + return dataPart; + } + return null; + } catch (error) { + console.error( + "[TaskSync] Failed to read image attachment:", + error, + ); + return null; + } + }); + + const imageParts = await Promise.all(imagePromises); + for (const part of imageParts) { + if (part !== null) { + resultParts.push(part); + } + } + } + + debugLog( + "[TaskSync] LM tool — returning LanguageModelToolResult to AI (parts:", + resultParts.length, + ")", + ); + return new vscode.LanguageModelToolResult(resultParts); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + console.error("[TaskSync] LM tool invoke error:", message); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart("Error: " + message), + ]); + } + }, + }); + + context.subscriptions.push(askUserTool); +} diff --git a/tasksync-chat/src/utils/generateId.test.ts b/tasksync-chat/src/utils/generateId.test.ts new file mode 100644 index 0000000..3487cfc --- /dev/null +++ b/tasksync-chat/src/utils/generateId.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { generateId } from "../utils/generateId"; + +describe("generateId", () => { + it("returns a string starting with the given prefix", () => { + const id = generateId("q"); + expect(id.startsWith("q_")).toBe(true); + }); + + it("contains a timestamp segment", () => { + const before = Date.now(); + const id = generateId("tc"); + const after = Date.now(); + + const parts = id.split("_"); + // format: prefix_timestamp_random + expect(parts.length).toBe(3); + + const ts = Number(parts[1]); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + + it("contains a random alphanumeric suffix", () => { + const id = generateId("rp"); + const parts = id.split("_"); + const random = parts[2]; + + expect(random.length).toBeGreaterThanOrEqual(1); + expect(random.length).toBeLessThanOrEqual(9); + // base-36 chars only + expect(random).toMatch(/^[a-z0-9]+$/); + }); + + it("generates unique IDs on successive calls", () => { + const ids = new Set(Array.from({ length: 50 }, () => generateId("u"))); + expect(ids.size).toBe(50); + }); + + it("works with various prefix values", () => { + for (const prefix of ["q", "tc", "rp", "att", "prob", "term", "ctx"]) { + const id = generateId(prefix); + expect(id.startsWith(`${prefix}_`)).toBe(true); + } + }); + + it("handles empty prefix", () => { + const id = generateId(""); + expect(id.startsWith("_")).toBe(true); + }); +}); diff --git a/tasksync-chat/src/utils/generateId.ts b/tasksync-chat/src/utils/generateId.ts new file mode 100644 index 0000000..d6eae83 --- /dev/null +++ b/tasksync-chat/src/utils/generateId.ts @@ -0,0 +1,6 @@ +/** + * Generate a unique ID with the given prefix (e.g. "q", "tc", "rp", "att", "prob", "term", "ctx"). + */ +export function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; +} diff --git a/tasksync-chat/src/utils/imageUtils.test.ts b/tasksync-chat/src/utils/imageUtils.test.ts new file mode 100644 index 0000000..e1c32d1 --- /dev/null +++ b/tasksync-chat/src/utils/imageUtils.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { getImageMimeType } from "../utils/imageUtils"; + +describe("getImageMimeType", () => { + it("returns correct MIME type for common image extensions", () => { + expect(getImageMimeType("photo.png")).toBe("image/png"); + expect(getImageMimeType("photo.jpg")).toBe("image/jpeg"); + expect(getImageMimeType("photo.jpeg")).toBe("image/jpeg"); + expect(getImageMimeType("animation.gif")).toBe("image/gif"); + expect(getImageMimeType("modern.webp")).toBe("image/webp"); + expect(getImageMimeType("bitmap.bmp")).toBe("image/bmp"); + expect(getImageMimeType("vector.svg")).toBe("image/svg+xml"); + expect(getImageMimeType("favicon.ico")).toBe("image/x-icon"); + expect(getImageMimeType("scan.tiff")).toBe("image/tiff"); + expect(getImageMimeType("scan.tif")).toBe("image/tiff"); + }); + + it("is case-insensitive for extensions", () => { + expect(getImageMimeType("FILE.PNG")).toBe("image/png"); + expect(getImageMimeType("photo.JPG")).toBe("image/jpeg"); + expect(getImageMimeType("icon.SVG")).toBe("image/svg+xml"); + }); + + it("returns application/octet-stream for unknown extensions", () => { + expect(getImageMimeType("document.pdf")).toBe("application/octet-stream"); + expect(getImageMimeType("data.json")).toBe("application/octet-stream"); + expect(getImageMimeType("archive.zip")).toBe("application/octet-stream"); + }); + + it("returns application/octet-stream for files without extension", () => { + expect(getImageMimeType("README")).toBe("application/octet-stream"); + expect(getImageMimeType("Makefile")).toBe("application/octet-stream"); + }); + + it("handles paths with directories", () => { + expect(getImageMimeType("/Users/me/photos/img.png")).toBe("image/png"); + expect(getImageMimeType("C:\\Users\\me\\img.jpg")).toBe("image/jpeg"); + expect(getImageMimeType("./relative/path/icon.gif")).toBe("image/gif"); + }); + + it("handles dotfiles", () => { + expect(getImageMimeType(".hidden")).toBe("application/octet-stream"); + }); + + it("handles double extensions (uses last)", () => { + expect(getImageMimeType("file.tar.png")).toBe("image/png"); + expect(getImageMimeType("backup.jpg.bak")).toBe("application/octet-stream"); + }); +}); diff --git a/tasksync-chat/src/utils/imageUtils.ts b/tasksync-chat/src/utils/imageUtils.ts index 930efc4..3727a86 100644 --- a/tasksync-chat/src/utils/imageUtils.ts +++ b/tasksync-chat/src/utils/imageUtils.ts @@ -1,4 +1,4 @@ -import * as path from 'path'; +import * as path from "path"; /** * Get MIME type for an image file based on its extension @@ -6,18 +6,18 @@ import * as path from 'path'; * @returns MIME type string (e.g., 'image/png') or 'application/octet-stream' if unknown */ export function getImageMimeType(filePath: string): string { - const extension = path.extname(filePath).toLowerCase(); - const mimeTypes: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.tiff': 'image/tiff', - '.tif': 'image/tiff', - }; - return mimeTypes[extension] || 'application/octet-stream'; + const extension = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".tiff": "image/tiff", + ".tif": "image/tiff", + }; + return mimeTypes[extension] || "application/octet-stream"; } diff --git a/tasksync-chat/src/webview-ui/adapter.js b/tasksync-chat/src/webview-ui/adapter.js new file mode 100644 index 0000000..c0ab1f6 --- /dev/null +++ b/tasksync-chat/src/webview-ui/adapter.js @@ -0,0 +1,707 @@ +// ==================== Communication Adapter ==================== +// Provides unified API for VS Code postMessage or WebSocket communication +const isRemoteMode = typeof acquireVsCodeApi === "undefined"; +// Debug mode: enable via localStorage.setItem('TASKSYNC_DEBUG', 'true') +const REMOTE_DEBUG = + isRemoteMode && localStorage.getItem("TASKSYNC_DEBUG") === "true"; +function debugLog(...args) { + if (REMOTE_DEBUG) console.log("[TaskSync Debug]", ...args); +} +let ws = null; +let wsReconnectAttempt = 0; +let wsState = {}; // Persisted state for remote mode +let wsConnecting = false; // Debounce flag to prevent rapid reconnect attempts +let pendingCriticalMessage = null; // Critical message awaiting send (tool responses) +let pendingOutboundMessages = []; // Non-critical messages queued while disconnected +const MAX_PENDING_OUTBOUND_MESSAGES = 100; +const REPLACEABLE_OUTBOUND_TYPES = new Set([ + "toggleQueue", + "toggleAutopilot", + "updateResponseTimeout", + "searchFiles", + "getState", + "chatCancel", +]); +let processingCheckTimer = null; // Timer to poll server when "Working..." is shown +let wsReconnectTimer = null; // Timer for scheduled reconnect + +function queueOutboundMessage(remoteMsg) { + if (!remoteMsg || !remoteMsg.type) return; + + if (REPLACEABLE_OUTBOUND_TYPES.has(remoteMsg.type)) { + for (var i = pendingOutboundMessages.length - 1; i >= 0; i--) { + if (pendingOutboundMessages[i].type === remoteMsg.type) { + pendingOutboundMessages[i] = remoteMsg; + return; + } + } + } + + if (pendingOutboundMessages.length >= MAX_PENDING_OUTBOUND_MESSAGES) { + pendingOutboundMessages.shift(); + } + pendingOutboundMessages.push(remoteMsg); +} + +function flushQueuedOutboundMessages() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + if (pendingOutboundMessages.length > 0) { + debugLog( + "Flushing queued outbound messages:", + pendingOutboundMessages.length, + ); + while ( + pendingOutboundMessages.length > 0 && + ws && + ws.readyState === WebSocket.OPEN + ) { + ws.send(JSON.stringify(pendingOutboundMessages.shift())); + } + } + + if (pendingCriticalMessage && ws && ws.readyState === WebSocket.OPEN) { + if (pendingToolCall && pendingCriticalMessage.id === pendingToolCall.id) { + ws.send(JSON.stringify(pendingCriticalMessage)); + } else if (!pendingToolCall) { + ws.send(JSON.stringify(pendingCriticalMessage)); + } else { + // Stale queued critical message — drop silently + } + pendingCriticalMessage = null; + } +} + +// Create adapter that works in both environments +const vscode = isRemoteMode ? createRemoteAdapter() : acquireVsCodeApi(); + +function createRemoteAdapter() { + debugLog("Creating remote adapter"); + // Load state from sessionStorage for remote mode + try { + const saved = sessionStorage.getItem(SESSION_KEYS.STATE); + if (saved) wsState = JSON.parse(saved); + } catch (e) { + wsState = {}; + } + + return { + postMessage: function (msg) { + debugLog("postMessage called:", msg.type); + const remoteMsg = mapToRemoteMessage(msg); + if (!remoteMsg) return; + + const isCritical = remoteMsg.type === "respond"; + const wsReady = ws && ws.readyState === WebSocket.OPEN; + + if (wsReady) { + ws.send(JSON.stringify(remoteMsg)); + if (isCritical) pendingCriticalMessage = null; + } else if (isCritical) { + pendingCriticalMessage = remoteMsg; + } else { + queueOutboundMessage(remoteMsg); + } + }, + getState: function () { + return wsState; + }, + setState: function (state) { + wsState = state; + try { + sessionStorage.setItem(SESSION_KEYS.STATE, JSON.stringify(state)); + } catch (e) { + console.error("[TaskSync] Failed to save state:", e); + } + }, + }; +} + +function mapToRemoteMessage(msg) { + // Map VS Code webview messages to remote server messages + switch (msg.type) { + case "submit": + // Response to a pending tool call + if (pendingToolCall) { + return { + type: "respond", + id: pendingToolCall.id, + value: msg.value, + attachments: msg.attachments || [], + }; + } + // No pending tool call — add to queue instead (matches VS Code behavior) + return { + type: "addToQueue", + prompt: msg.value, + attachments: msg.attachments || [], + }; + case "addQueuePrompt": + return { + type: "addToQueue", + prompt: msg.prompt, + attachments: msg.attachments || [], + }; + case "removeQueuePrompt": + return { type: "removeFromQueue", id: msg.promptId }; + case "editQueuePrompt": + return { + type: "editQueuePrompt", + promptId: msg.promptId, + newPrompt: msg.newPrompt, + }; + case "reorderQueue": + return { + type: "reorderQueue", + fromIndex: msg.fromIndex, + toIndex: msg.toIndex, + }; + case "clearQueue": + return { type: "clearQueue" }; + case "toggleQueue": + return { type: "toggleQueue", enabled: msg.enabled }; + case "updateAutopilotSetting": + return { type: "toggleAutopilot", enabled: msg.enabled }; + case "updateResponseTimeout": + return { type: "updateResponseTimeout", timeout: msg.value }; + case "newSession": + return { type: "newSession" }; + case "chatMessage": + return { type: "chatMessage", content: msg.content }; + case "chatFollowUp": + return { type: "chatFollowUp", content: msg.content }; + case "chatCancel": + return { type: "chatCancel" }; + case "startSession": + return { type: "startSession", prompt: msg.prompt || "" }; + case "webviewReady": + return { type: "getState" }; + // Messages that don't apply to remote (VS Code specific) + case "openExternal": + // Open external links in new tab in remote mode + if (msg.url) { + window.open(msg.url, "_blank", "noopener,noreferrer"); + } + return null; + case "addAttachment": + case "openLink": + case "openHistoryModal": + case "openSettingsModal": + return null; // Handle locally or ignore + case "searchFiles": + return { type: "searchFiles", query: msg.query }; + // VS Code-only settings/UI messages — not applicable to remote + case "updateSoundSetting": + case "updateInteractiveApprovalSetting": + case "updateSendWithCtrlEnterSetting": + case "updateHumanDelaySetting": + case "updateHumanDelayMin": + case "updateHumanDelayMax": + case "updateSessionWarningHours": + case "updateMaxConsecutiveAutoResponses": + case "addAutopilotPrompt": + case "editAutopilotPrompt": + case "removeAutopilotPrompt": + case "reorderAutopilotPrompts": + case "addReusablePrompt": + case "editReusablePrompt": + case "removeReusablePrompt": + case "updateAutopilotText": + case "searchSlashCommands": + return msg; // Forward settings to server + case "addFileReference": + case "copyToClipboard": + case "saveImage": + case "removeHistoryItem": + case "clearPersistedHistory": + case "openFileLink": + case "searchContext": + case "selectContextReference": + return null; + default: + // Pass through unknown messages + return msg; + } +} + +let serverShutdown = false; // Track if server was intentionally stopped + +// Update connection status indicator in remote header +function updateRemoteConnectionStatus(status, reason) { + let indicator = document.getElementById("remote-connection-status"); + if (indicator) { + indicator.className = "remote-status " + status; + if (status === "connected") { + indicator.title = "Connected"; + } else if (reason === "shutdown") { + indicator.title = "Server stopped"; + } else if (reason === "max-attempts") { + indicator.title = "Connection failed - server unreachable"; + } else { + indicator.title = "Disconnected - reconnecting..."; + } + } +} + +// Initialize WebSocket for remote mode +if (isRemoteMode) { + connectRemoteWebSocket(); + // Cleanup on page unload to prevent reconnection attempts during teardown + window.addEventListener("beforeunload", function () { + clearTimeout(wsReconnectTimer); + clearTimeout(processingCheckTimer); + serverShutdown = true; // Prevent reconnection + }); +} + +function connectRemoteWebSocket() { + if (wsConnecting) return; + wsConnecting = true; + + // Close any existing connection before creating new one + if (ws) { + try { + ws.close(); + } catch (e) { + console.error("[TaskSync] Failed to close WebSocket:", e); + } + ws = null; + } + serverShutdown = false; + + ws = new WebSocket(`${getWsProtocol()}//${location.host}`); + + ws.onopen = function () { + wsConnecting = false; + wsReconnectAttempt = 0; + const sessionToken = + sessionStorage.getItem(SESSION_KEYS.SESSION_TOKEN) || ""; + const pin = sessionStorage.getItem(SESSION_KEYS.PIN) || ""; + ws.send( + JSON.stringify({ type: "auth", pin: pin, sessionToken: sessionToken }), + ); + }; + + ws.onmessage = function (e) { + try { + const msg = JSON.parse(e.data); + debugLog("WS received:", msg.type, msg); + handleRemoteMessage(msg); + } catch (err) { + console.error("[TaskSync Remote] Message error:", err); + } + }; + + ws.onclose = function (event) { + wsConnecting = false; + clearTimeout(processingCheckTimer); + if (serverShutdown) { + updateRemoteConnectionStatus("disconnected", "shutdown"); + // Don't reconnect - server was intentionally stopped + return; + } + // 1013 = Try Again Later (server at capacity) + if (event.code === 1013) { + updateRemoteConnectionStatus( + "disconnected", + "Server at capacity — retry later", + ); + return; + } + updateRemoteConnectionStatus("disconnected"); + scheduleRemoteReconnect(); + }; + + ws.onerror = function () { + wsConnecting = false; + updateRemoteConnectionStatus("disconnected"); + }; +} + +function scheduleRemoteReconnect() { + if (serverShutdown) { + debugLog("Reconnect skipped (server shutdown)"); + return; + } + wsReconnectAttempt++; + debugLog("Scheduling reconnect attempt:", wsReconnectAttempt); + if (wsReconnectAttempt > MAX_RECONNECT_ATTEMPTS) { + updateRemoteConnectionStatus("disconnected", "max-attempts"); + console.error("[TaskSync Remote] Max reconnection attempts reached."); + return; + } + const delay = Math.min( + 1000 * Math.pow(1.5, wsReconnectAttempt), + MAX_RECONNECT_DELAY_MS, + ); + debugLog("Reconnect in", delay, "ms"); + wsReconnectTimer = setTimeout(connectRemoteWebSocket, delay); +} + +function handleRemoteMessage(msg) { + debugLog("Handling message:", msg.type); + switch (msg.type) { + case "serverShutdown": + debugLog("Server shutdown received"); + serverShutdown = true; + updateRemoteConnectionStatus("disconnected", "shutdown"); + if (editingPromptId) exitEditMode(); + // Show user-friendly message + alert( + "Server stopped: " + + (msg.reason || "The remote server has been stopped."), + ); + return; + case "connected": + case "authSuccess": + if ( + msg.protocolVersion !== undefined && + msg.protocolVersion !== TASKSYNC_PROTOCOL_VERSION + ) + console.error( + "[TaskSync Remote] Protocol version mismatch: server=" + + msg.protocolVersion + + " client=" + + TASKSYNC_PROTOCOL_VERSION, + ); + debugLog( + "Auth success, hasState:", + !!msg.state, + "hasSessionToken:", + !!msg.sessionToken, + ); + if (msg.state) applyServerState(msg.state); + if (msg.sessionToken) + sessionStorage.setItem(SESSION_KEYS.SESSION_TOKEN, msg.sessionToken); + updateRemoteConnectionStatus("connected"); + flushQueuedOutboundMessages(); + break; + case "authFailed": + debugLog("Auth failed, redirecting to login"); + sessionStorage.removeItem(SESSION_KEYS.CONNECTED); + sessionStorage.removeItem(SESSION_KEYS.PIN); + sessionStorage.removeItem(SESSION_KEYS.STATE); + sessionStorage.removeItem(SESSION_KEYS.SESSION_TOKEN); + window.location.href = "index.html"; + break; + case "requireAuth": + // Server sends this before auth is processed — handled by onopen auth flow + break; + case "toolCallPending": + if (!msg.data) break; + debugLog( + "toolCallPending:", + msg.data.id, + "isApproval:", + msg.data.isApproval, + ); + clearTimeout(processingCheckTimer); + showPendingToolCall( + msg.data.id, + msg.data.prompt, + msg.data.isApproval, + msg.data.choices, + msg.data.summary, + ); + if (typeof playNotificationSound === "function") playNotificationSound(); + break; + case "toolCallCompleted": + if (!msg.data) break; + debugLog( + "toolCallCompleted:", + msg.data.entry?.id, + "sessionTerminated:", + msg.data.sessionTerminated, + ); + document.body.classList.remove("has-pending-toolcall"); + if (typeof hideApprovalModal === "function") hideApprovalModal(); + if (typeof hideChoicesBar === "function") hideChoicesBar(); + if (msg.data.entry) { + if (!msg.data.entry.status) msg.data.entry.status = "completed"; + currentSessionCalls = currentSessionCalls.filter(function (tc) { + return tc.id !== msg.data.entry.id; + }); + currentSessionCalls = [msg.data.entry, ...currentSessionCalls].slice( + 0, + MAX_DISPLAY_HISTORY, + ); + renderCurrentSession(); + } + pendingToolCall = null; + if (typeof scrollToBottom === "function") scrollToBottom(); + if (msg.data.sessionTerminated) { + isProcessingResponse = false; + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    Session terminated' + + '
    '; + var tBtn = document.getElementById( + "remote-terminated-new-session-btn", + ); + if (tBtn) + tBtn.addEventListener("click", function () { + openNewSessionModal(); + }); + } + } else { + isProcessingResponse = true; + updatePendingUI(); + clearTimeout(processingCheckTimer); + processingCheckTimer = setTimeout(function () { + if ( + isProcessingResponse && + !pendingToolCall && + ws && + ws.readyState === WebSocket.OPEN + ) + ws.send(JSON.stringify({ type: "getState" })); + }, PROCESSING_POLL_INTERVAL_MS); + } + break; + case "queueChanged": + if (!msg.data) break; + // Update queue version for optimistic concurrency control + if (msg.data.queueVersion !== undefined) { + queueVersion = msg.data.queueVersion; + } + promptQueue = msg.data.queue || []; + renderQueue(); + if (typeof updateCardSelection === "function") updateCardSelection(); + updateQueueVisibility(); + break; + case "settingsChanged": + if (!msg.data) break; + debugLog("settingsChanged:", Object.keys(msg.data)); + applySettingsData(msg.data); + break; + case "newSession": + debugLog("newSession received - clearing state"); + clearTimeout(processingCheckTimer); + currentSessionCalls = []; + pendingToolCall = null; + isProcessingResponse = false; + if (chatStreamArea) { + chatStreamArea.innerHTML = ""; + chatStreamArea.classList.add("hidden"); + } + document.body.classList.remove("has-pending-toolcall"); + if (typeof hideApprovalModal === "function") hideApprovalModal(); + if (typeof hideChoicesBar === "function") hideChoicesBar(); + renderCurrentSession(); + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    ' + + ' New session started \u2014 waiting for AI' + + "
    "; + } + if (typeof updateWelcomeSectionVisibility === "function") + updateWelcomeSectionVisibility(); + debugLog("newSession complete - state cleared"); + break; + case "fileSearchResults": + showAutocomplete(msg.files || []); + break; + case "slashCommandResults": + if (typeof showSlashDropdown === "function") + showSlashDropdown(msg.prompts || []); + break; + case "state": + debugLog( + "Full state refresh:", + msg.data ? Object.keys(msg.data) : "no data", + ); + if (msg.data) applyServerState(msg.data); + break; + case "changes": + case "diff": + case "staged": + case "unstaged": + case "stagedAll": + case "discarded": + case "committed": + case "pushed": + case "changesUpdated": + // Git responses handled silently + break; + case "error": + // Error messages can come from broadcast (wrapped in {type, data}) or sendWsError (top-level) + var errMsg = msg.message || (msg.data && msg.data.message); + var errCode = msg.code || (msg.data && msg.data.code); + console.error("[TaskSync Remote] Server error:", errMsg); + if (errMsg === "Not authenticated") { + sessionStorage.removeItem(SESSION_KEYS.CONNECTED); + window.location.href = "index.html"; + } else if ( + errCode === "ALREADY_ANSWERED" || + errCode === "ITEM_NOT_FOUND" || + errCode === "QUEUE_FULL" || + errCode === "INVALID_INPUT" + ) { + alert(errMsg); + } + break; + } +} + +// ——— Server state application (SSOT) ——— + +// Apply settings data from either settingsChanged broadcast or getState response (SSOT) +function applySettingsData(s) { + if (s.autopilotEnabled !== undefined) autopilotEnabled = s.autopilotEnabled; + if (s.queueEnabled !== undefined) { + queueEnabled = s.queueEnabled; + updateQueueVisibility(); + } + if (s.responseTimeout !== undefined) responseTimeout = s.responseTimeout; + if (s.soundEnabled !== undefined) soundEnabled = s.soundEnabled; + if (s.interactiveApprovalEnabled !== undefined) + interactiveApprovalEnabled = s.interactiveApprovalEnabled; + if (s.sendWithCtrlEnter !== undefined) + sendWithCtrlEnter = s.sendWithCtrlEnter; + if (s.sessionWarningHours !== undefined) + sessionWarningHours = s.sessionWarningHours; + if (s.maxConsecutiveAutoResponses !== undefined) + maxConsecutiveAutoResponses = s.maxConsecutiveAutoResponses; + if (s.humanLikeDelayEnabled !== undefined) + humanLikeDelayEnabled = s.humanLikeDelayEnabled; + if (s.humanLikeDelayMin !== undefined) + humanLikeDelayMin = s.humanLikeDelayMin; + if (s.humanLikeDelayMax !== undefined) + humanLikeDelayMax = s.humanLikeDelayMax; + if (s.autopilotPrompts !== undefined) autopilotPrompts = s.autopilotPrompts; + if (s.reusablePrompts !== undefined) reusablePrompts = s.reusablePrompts; + updateModeUI(); + applySettingsToUI(); +} + +// Apply server state (SSOT - single function for all state updates) +function applyServerState(state) { + if (state.queue) { + promptQueue = state.queue; + renderQueue(); + } + if (state.queueVersion !== undefined) { + queueVersion = state.queueVersion; + } + if (state.pending) { + handlePendingToolCall(state.pending); + } else { + // No pending tool call — clear any stale pending state + pendingToolCall = null; + document.body.classList.remove("has-pending-toolcall"); + if (typeof hideApprovalModal === "function") hideApprovalModal(); + if (typeof hideChoicesBar === "function") hideChoicesBar(); + } + // Use server processing flag or pending inference + isProcessingResponse = state.isProcessing ?? state.pending !== null; + if (state.history) { + currentSessionCalls = state.history; + renderCurrentSession(); + } + if (state.settings) applySettingsData(state.settings); + updatePendingUI(); + if (typeof updateCardSelection === "function") updateCardSelection(); + if (typeof updateWelcomeSectionVisibility === "function") + updateWelcomeSectionVisibility(); +} + +function handlePendingToolCall(data) { + debugLog( + "handlePendingToolCall — id:", + data.id, + "hasSummary:", + !!data.summary, + "summaryLength:", + data.summary ? data.summary.length : 0, + "promptLength:", + data.prompt ? data.prompt.length : 0, + ); + if (typeof showPendingToolCall === "function") { + showPendingToolCall( + data.id, + data.prompt, + data.isApproval, + data.choices, + data.summary, + ); + } else { + pendingToolCall = data; + isApprovalQuestion = data.isApproval || false; + currentChoices = (data.choices || []).map(function (c) { + return typeof c === "string" ? { label: c, value: c, shortLabel: c } : c; + }); + isProcessingResponse = false; + updatePendingUI(); + } +} + +let wasProcessing = false; // Track processing→idle transition +function updatePendingUI() { + if (!pendingMessage) return; + + if (pendingToolCall) { + wasProcessing = false; + pendingMessage.classList.remove("hidden"); + let pendingHtml = ""; + if (pendingToolCall.summary) { + debugLog( + "Rendering summary in remote pending view — length:", + pendingToolCall.summary.length, + "preview:", + pendingToolCall.summary.slice(0, 80), + ); + pendingHtml += + '
    ' + + (typeof formatMarkdown === "function" + ? formatMarkdown(pendingToolCall.summary) + : escapeHtml(pendingToolCall.summary)) + + "
    "; + } else { + debugLog("No summary to render in remote pending view"); + } + pendingHtml += + '
    ' + + (typeof formatMarkdown === "function" + ? formatMarkdown(pendingToolCall.prompt || "") + : escapeHtml(pendingToolCall.prompt || "")) + + "
    "; + debugLog("Remote pending HTML set — totalLength:", pendingHtml.length); + pendingMessage.innerHTML = pendingHtml; + } else if (isProcessingResponse) { + wasProcessing = true; + // AI is processing the response — show working indicator + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    Working\u2026
    '; + } else if (wasProcessing && currentSessionCalls.length > 0) { + wasProcessing = false; + // AI was working but stopped without calling askUser — show idle notice + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    Agent finished \u2014 type a message to continue
    '; + } else { + wasProcessing = false; + pendingMessage.classList.add("hidden"); + pendingMessage.innerHTML = ""; + } +} + +/** Refresh all settings toggle/input UI from current state variables. */ +function applySettingsToUI() { + updateSoundToggleUI(); + updateInteractiveApprovalToggleUI(); + updateSendWithCtrlEnterToggleUI(); + updateAutopilotToggleUI(); + updateResponseTimeoutUI(); + updateSessionWarningHoursUI(); + updateMaxAutoResponsesUI(); + updateHumanDelayUI(); + renderAutopilotPromptsList(); + renderPromptsList(); + updateQueueVisibility(); +} + +// ==================== End Communication Adapter ==================== diff --git a/tasksync-chat/src/webview-ui/approval.js b/tasksync-chat/src/webview-ui/approval.js new file mode 100644 index 0000000..b6ad3ec --- /dev/null +++ b/tasksync-chat/src/webview-ui/approval.js @@ -0,0 +1,214 @@ +// ==================== Approval Modal ==================== + +/** + * Show approval modal + */ +function showApprovalModal() { + if (!approvalModal) return; + approvalModal.classList.remove("hidden"); + // Focus chat input instead of Yes button to prevent accidental Enter approvals + // User can still click Yes/No or use keyboard navigation + if (chatInput) { + chatInput.focus(); + } +} + +/** + * Hide approval modal + */ +function hideApprovalModal() { + if (!approvalModal) return; + approvalModal.classList.add("hidden"); + isApprovalQuestion = false; +} + +/** + * Show choices bar with toggleable multi-select buttons + */ +function showChoicesBar() { + // Hide approval modal first + hideApprovalModal(); + + // Create or get choices bar + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) { + choicesBar = document.createElement("div"); + choicesBar.className = "choices-bar"; + choicesBar.id = "choices-bar"; + choicesBar.setAttribute("role", "toolbar"); + choicesBar.setAttribute("aria-label", "Quick choice options"); + + // Insert at top of input-wrapper + let inputWrapper = document.getElementById("input-wrapper"); + if (inputWrapper) { + inputWrapper.insertBefore(choicesBar, inputWrapper.firstChild); + } + } + + // Build toggleable choice buttons + let buttonsHtml = currentChoices + .map(function (choice) { + let shortLabel = choice.shortLabel || choice.value; + let title = choice.label || choice.value; + return ( + '" + ); + }) + .join(""); + + choicesBar.innerHTML = + 'Choose:' + + '
    ' + + buttonsHtml + + "
    " + + '
    ' + + '' + + '' + + "
    "; + + // Bind click events to choice buttons (toggle selection) + choicesBar.querySelectorAll(".choice-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + handleChoiceToggle(btn); + }); + }); + + // Bind 'All' button + let allBtn = choicesBar.querySelector(".choices-all-btn"); + if (allBtn) { + allBtn.addEventListener("click", handleChoicesSelectAll); + } + + // Bind 'Send' button + let choicesSendBtn = choicesBar.querySelector(".choices-send-btn"); + if (choicesSendBtn) { + choicesSendBtn.addEventListener("click", handleChoicesSend); + } + + choicesBar.classList.remove("hidden"); + + // Focus chat input for immediate typing + if (chatInput) { + chatInput.focus(); + } +} + +/** + * Hide choices bar + */ +function hideChoicesBar() { + let choicesBar = document.getElementById("choices-bar"); + if (choicesBar) { + choicesBar.classList.add("hidden"); + } + currentChoices = []; +} + +/** + * Toggle a choice button's selected state + */ +function handleChoiceToggle(btn) { + if (!pendingToolCall) return; + + let isSelected = btn.classList.toggle("selected"); + btn.setAttribute("aria-pressed", isSelected ? "true" : "false"); + + updateChoicesSendButton(); +} + +/** + * Toggle all choices selected/deselected + */ +function handleChoicesSelectAll() { + if (!pendingToolCall) return; + + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) return; + + let buttons = choicesBar.querySelectorAll(".choice-btn"); + let allSelected = Array.from(buttons).every(function (btn) { + return btn.classList.contains("selected"); + }); + + buttons.forEach(function (btn) { + if (allSelected) { + btn.classList.remove("selected"); + btn.setAttribute("aria-pressed", "false"); + } else { + btn.classList.add("selected"); + btn.setAttribute("aria-pressed", "true"); + } + }); + + updateChoicesSendButton(); +} + +/** + * Send all selected choices as a comma-separated response + */ +function handleChoicesSend() { + if (!pendingToolCall) return; + + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) return; + + let selectedButtons = choicesBar.querySelectorAll(".choice-btn.selected"); + if (selectedButtons.length === 0) return; + + let values = Array.from(selectedButtons).map(function (btn) { + return btn.getAttribute("data-value"); + }); + let responseValue = values.join(", "); + + // Hide choices bar + hideChoicesBar(); + + // Send the response + vscode.postMessage({ type: "submit", value: responseValue, attachments: [] }); + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + saveWebviewState(); +} + +/** + * Update the Send button state and All button label based on current selections + */ +function updateChoicesSendButton() { + let choicesBar = document.getElementById("choices-bar"); + if (!choicesBar) return; + + let selectedCount = choicesBar.querySelectorAll( + ".choice-btn.selected", + ).length; + let totalCount = choicesBar.querySelectorAll(".choice-btn").length; + let choicesSendBtn = choicesBar.querySelector(".choices-send-btn"); + let allBtn = choicesBar.querySelector(".choices-all-btn"); + + if (choicesSendBtn) { + choicesSendBtn.disabled = selectedCount === 0; + choicesSendBtn.textContent = + selectedCount > 0 ? "Send (" + selectedCount + ")" : "Send"; + } + + if (allBtn) { + let isAllSelected = totalCount > 0 && selectedCount === totalCount; + allBtn.textContent = isAllSelected ? "None" : "All"; + let allBtnActionLabel = isAllSelected ? "Deselect all" : "Select all"; + allBtn.title = allBtnActionLabel; + allBtn.setAttribute("aria-label", allBtnActionLabel); + } +} diff --git a/tasksync-chat/src/webview-ui/constants.js b/tasksync-chat/src/webview-ui/constants.js new file mode 100644 index 0000000..50eb2fe --- /dev/null +++ b/tasksync-chat/src/webview-ui/constants.js @@ -0,0 +1,30 @@ +// ==================== Shared Constants (SSOT) ==================== +// Use shared constants if available (remote mode), otherwise define inline (VS Code mode) +const SESSION_KEYS = + typeof TASKSYNC_SESSION_KEYS !== "undefined" + ? TASKSYNC_SESSION_KEYS + : { + STATE: "taskSyncState", + PIN: "taskSyncPin", + CONNECTED: "taskSyncConnected", + SESSION_TOKEN: "taskSyncSessionToken", + }; + +const MAX_RECONNECT_ATTEMPTS = + typeof TASKSYNC_MAX_RECONNECT_ATTEMPTS !== "undefined" + ? TASKSYNC_MAX_RECONNECT_ATTEMPTS + : 20; + +const MAX_RECONNECT_DELAY_MS = + typeof TASKSYNC_MAX_RECONNECT_DELAY_MS !== "undefined" + ? TASKSYNC_MAX_RECONNECT_DELAY_MS + : 30000; // 30 seconds max reconnect delay + +const getWsProtocol = + typeof getTaskSyncWsProtocol !== "undefined" + ? getTaskSyncWsProtocol + : function () { + return location.protocol === "https:" ? "wss:" : "ws:"; + }; + +const PROCESSING_POLL_INTERVAL_MS = 5000; // Delay before polling server for state after tool call diff --git a/tasksync-chat/src/webview-ui/events.js b/tasksync-chat/src/webview-ui/events.js new file mode 100644 index 0000000..74be876 --- /dev/null +++ b/tasksync-chat/src/webview-ui/events.js @@ -0,0 +1,220 @@ +// ==================== Event Listeners ==================== + +function bindEventListeners() { + if (chatInput) { + chatInput.addEventListener("input", handleTextareaInput); + chatInput.addEventListener("keydown", handleTextareaKeydown); + chatInput.addEventListener("paste", handlePaste); + // Sync scroll between textarea and highlighter + chatInput.addEventListener("scroll", function () { + if (inputHighlighter) { + inputHighlighter.scrollTop = chatInput.scrollTop; + } + }); + } + if (sendBtn) sendBtn.addEventListener("click", handleSend); + if (attachBtn) attachBtn.addEventListener("click", handleAttach); + if (modeBtn) modeBtn.addEventListener("click", toggleModeDropdown); + + document + .querySelectorAll(".mode-option[data-mode]") + .forEach(function (option) { + option.addEventListener("click", function () { + setMode(option.getAttribute("data-mode"), true); + closeModeDropdown(); + }); + }); + + document.addEventListener("click", function (e) { + let markdownLink = + e.target && e.target.closest + ? e.target.closest("a.markdown-link[data-link-target]") + : null; + if (markdownLink) { + e.preventDefault(); + let markdownLinksApi = window.TaskSyncMarkdownLinks; + let encodedTarget = markdownLink.getAttribute("data-link-target"); + if ( + encodedTarget && + markdownLinksApi && + typeof markdownLinksApi.toWebviewMessage === "function" + ) { + const linkMessage = markdownLinksApi.toWebviewMessage(encodedTarget); + if (linkMessage) { + vscode.postMessage(linkMessage); + } + } + return; + } + + if ( + dropdownOpen && + !e.target.closest(".mode-selector") && + !e.target.closest(".mode-dropdown") + ) + closeModeDropdown(); + if ( + autocompleteVisible && + !e.target.closest(".autocomplete-dropdown") && + !e.target.closest("#chat-input") + ) + hideAutocomplete(); + if ( + slashDropdownVisible && + !e.target.closest(".slash-dropdown") && + !e.target.closest("#chat-input") + ) + hideSlashDropdown(); + }); + + // Remember right-click target so context-menu Copy can resolve the exact clicked message. + document.addEventListener("contextmenu", handleContextMenu); + // Intercept Copy when nothing is selected and copy clicked message text as-is. + document.addEventListener("copy", handleCopy); + + if (queueHeader) + queueHeader.addEventListener("click", handleQueueHeaderClick); + if (historyModalClose) + historyModalClose.addEventListener("click", closeHistoryModal); + if (historyModalClearAll) + historyModalClearAll.addEventListener("click", clearAllPersistedHistory); + if (historyModalOverlay) { + historyModalOverlay.addEventListener("click", function (e) { + if (e.target === historyModalOverlay) closeHistoryModal(); + }); + } + // Edit mode button events + if (editCancelBtn) editCancelBtn.addEventListener("click", cancelEditMode); + if (editConfirmBtn) editConfirmBtn.addEventListener("click", confirmEditMode); + + // Approval modal button events + if (approvalContinueBtn) + approvalContinueBtn.addEventListener("click", handleApprovalContinue); + if (approvalNoBtn) approvalNoBtn.addEventListener("click", handleApprovalNo); + + // Settings modal events + if (settingsModalClose) + settingsModalClose.addEventListener("click", closeSettingsModal); + if (settingsModalOverlay) { + settingsModalOverlay.addEventListener("click", function (e) { + if (e.target === settingsModalOverlay) closeSettingsModal(); + }); + } + if (soundToggle) { + soundToggle.addEventListener("click", toggleSoundSetting); + soundToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSoundSetting(); + } + }); + } + if (interactiveApprovalToggle) { + interactiveApprovalToggle.addEventListener( + "click", + toggleInteractiveApprovalSetting, + ); + interactiveApprovalToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleInteractiveApprovalSetting(); + } + }); + } + if (sendShortcutToggle) { + sendShortcutToggle.addEventListener( + "click", + toggleSendWithCtrlEnterSetting, + ); + sendShortcutToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSendWithCtrlEnterSetting(); + } + }); + } + if (autopilotToggle) { + autopilotToggle.addEventListener("click", toggleAutopilotSetting); + autopilotToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleAutopilotSetting(); + } + }); + } + // Autopilot prompts list event listeners + if (autopilotAddBtn) { + autopilotAddBtn.addEventListener("click", showAddAutopilotPromptForm); + } + if (saveAutopilotPromptBtn) { + saveAutopilotPromptBtn.addEventListener("click", saveAutopilotPrompt); + } + if (cancelAutopilotPromptBtn) { + cancelAutopilotPromptBtn.addEventListener( + "click", + hideAddAutopilotPromptForm, + ); + } + if (autopilotPromptsList) { + autopilotPromptsList.addEventListener( + "click", + handleAutopilotPromptsListClick, + ); + // Drag and drop for reordering + autopilotPromptsList.addEventListener( + "dragstart", + handleAutopilotDragStart, + ); + autopilotPromptsList.addEventListener("dragover", handleAutopilotDragOver); + autopilotPromptsList.addEventListener("dragend", handleAutopilotDragEnd); + autopilotPromptsList.addEventListener("drop", handleAutopilotDrop); + } + if (responseTimeoutSelect) { + responseTimeoutSelect.addEventListener( + "change", + handleResponseTimeoutChange, + ); + } + if (sessionWarningHoursSelect) { + sessionWarningHoursSelect.addEventListener( + "change", + handleSessionWarningHoursChange, + ); + } + if (maxAutoResponsesInput) { + maxAutoResponsesInput.addEventListener( + "change", + handleMaxAutoResponsesChange, + ); + maxAutoResponsesInput.addEventListener( + "blur", + handleMaxAutoResponsesChange, + ); + } + if (humanDelayToggle) { + humanDelayToggle.addEventListener("click", toggleHumanDelaySetting); + humanDelayToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleHumanDelaySetting(); + } + }); + } + if (humanDelayMinInput) { + humanDelayMinInput.addEventListener("change", handleHumanDelayMinChange); + humanDelayMinInput.addEventListener("blur", handleHumanDelayMinChange); + } + if (humanDelayMaxInput) { + humanDelayMaxInput.addEventListener("change", handleHumanDelayMaxChange); + humanDelayMaxInput.addEventListener("blur", handleHumanDelayMaxChange); + } + if (addPromptBtn) addPromptBtn.addEventListener("click", showAddPromptForm); + // Add prompt form events (deferred - bind after modal created) + let cancelPromptBtn = document.getElementById("cancel-prompt-btn"); + let savePromptBtn = document.getElementById("save-prompt-btn"); + if (cancelPromptBtn) + cancelPromptBtn.addEventListener("click", hideAddPromptForm); + if (savePromptBtn) savePromptBtn.addEventListener("click", saveNewPrompt); + + window.addEventListener("message", handleExtensionMessage); +} diff --git a/tasksync-chat/src/webview-ui/extras.js b/tasksync-chat/src/webview-ui/extras.js new file mode 100644 index 0000000..4daedea --- /dev/null +++ b/tasksync-chat/src/webview-ui/extras.js @@ -0,0 +1,635 @@ +// ===== NOTIFICATION SOUND FUNCTION ===== + +/** + * Unlock audio playback after first user interaction + * Required due to browser autoplay policy + */ +function unlockAudioOnInteraction() { + function unlock() { + if (audioUnlocked) return; + let audio = document.getElementById("notification-sound"); + if (audio) { + // Play and immediately pause to unlock + audio.volume = 0; + let playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise + .then(function () { + audio.pause(); + audio.currentTime = 0; + audio.volume = 0.5; + audioUnlocked = true; + }) + .catch(function () { + // Still locked, will try again on next interaction + }); + } + } + // Remove listeners after first attempt + document.removeEventListener("click", unlock); + document.removeEventListener("keydown", unlock); + } + document.addEventListener("click", unlock, { once: true }); + document.addEventListener("keydown", unlock, { once: true }); +} + +function playNotificationSound() { + // Play the preloaded audio element + try { + let audio = document.getElementById("notification-sound"); + if (audio) { + audio.currentTime = 0; // Reset to beginning + audio.volume = 0.5; + let playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise + .then(function () { + // Audio playback started + }) + .catch(function (e) { + // If autoplay blocked, show visual feedback + flashNotification(); + }); + } + } else { + flashNotification(); + } + } catch (e) { + flashNotification(); + } +} + +function flashNotification() { + // Visual flash when audio fails + let body = document.body; + body.style.transition = "background-color 0.1s ease"; + let originalBg = body.style.backgroundColor; + body.style.backgroundColor = "var(--vscode-textLink-foreground, #3794ff)"; + setTimeout(function () { + body.style.backgroundColor = originalBg || ""; + }, 150); +} + +function bindDragAndDrop() { + if (!queueList) return; + queueList.querySelectorAll(".queue-item").forEach(function (item) { + item.addEventListener("dragstart", function (e) { + e.dataTransfer.setData( + "text/plain", + String(parseInt(item.getAttribute("data-index"), 10)), + ); + item.classList.add("dragging"); + }); + item.addEventListener("dragend", function () { + item.classList.remove("dragging"); + }); + item.addEventListener("dragover", function (e) { + e.preventDefault(); + item.classList.add("drag-over"); + }); + item.addEventListener("dragleave", function () { + item.classList.remove("drag-over"); + }); + item.addEventListener("drop", function (e) { + e.preventDefault(); + let fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10); + let toIndex = parseInt(item.getAttribute("data-index"), 10); + item.classList.remove("drag-over"); + if (fromIndex !== toIndex && !isNaN(fromIndex) && !isNaN(toIndex)) + reorderQueue(fromIndex, toIndex); + }); + }); +} + +function bindKeyboardNavigation() { + if (!queueList) return; + let items = queueList.querySelectorAll(".queue-item"); + items.forEach(function (item, index) { + item.addEventListener("keydown", function (e) { + if (e.key === "ArrowDown" && index < items.length - 1) { + e.preventDefault(); + items[index + 1].focus(); + } else if (e.key === "ArrowUp" && index > 0) { + e.preventDefault(); + items[index - 1].focus(); + } else if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + var id = item.getAttribute("data-id"); + if (id) removeFromQueue(id); + } + }); + }); +} + +function reorderQueue(fromIndex, toIndex) { + let removed = promptQueue.splice(fromIndex, 1)[0]; + promptQueue.splice(toIndex, 0, removed); + renderQueue(); + vscode.postMessage({ + type: "reorderQueue", + fromIndex: fromIndex, + toIndex: toIndex, + }); +} + +function handleAutocomplete() { + if (!chatInput) return; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + let hashPos = -1; + for (var i = cursorPos - 1; i >= 0; i--) { + if (value[i] === "#") { + hashPos = i; + break; + } + if (value[i] === " " || value[i] === "\n") break; + } + if (hashPos >= 0) { + let query = value.substring(hashPos + 1, cursorPos); + autocompleteStartPos = hashPos; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(function () { + vscode.postMessage({ type: "searchFiles", query: query }); + }, 150); + } else if (autocompleteVisible) { + hideAutocomplete(); + } +} + +function showAutocomplete(results) { + if (!autocompleteDropdown || !autocompleteList || !autocompleteEmpty) return; + autocompleteResults = results; + selectedAutocompleteIndex = results.length > 0 ? 0 : -1; + if (results.length === 0) { + autocompleteList.classList.add("hidden"); + autocompleteEmpty.classList.remove("hidden"); + } else { + autocompleteList.classList.remove("hidden"); + autocompleteEmpty.classList.add("hidden"); + renderAutocompleteList(); + } + autocompleteDropdown.classList.remove("hidden"); + autocompleteVisible = true; +} + +function hideAutocomplete() { + if (autocompleteDropdown) autocompleteDropdown.classList.add("hidden"); + autocompleteVisible = false; + autocompleteResults = []; + selectedAutocompleteIndex = -1; + autocompleteStartPos = -1; + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } +} + +function renderAutocompleteList() { + if (!autocompleteList) return; + autocompleteList.innerHTML = autocompleteResults + .map(function (file, index) { + return ( + '
    ' + + '' + + '
    ' + + escapeHtml(file.name) + + "" + + '' + + escapeHtml(file.path) + + "
    " + ); + }) + .join(""); + + autocompleteList + .querySelectorAll(".autocomplete-item") + .forEach(function (item) { + item.addEventListener("click", function () { + selectAutocompleteItem(parseInt(item.getAttribute("data-index"), 10)); + }); + item.addEventListener("mouseenter", function () { + selectedAutocompleteIndex = parseInt( + item.getAttribute("data-index"), + 10, + ); + updateAutocompleteSelection(); + }); + }); + scrollToSelectedItem(); +} + +function updateAutocompleteSelection() { + if (!autocompleteList) return; + autocompleteList + .querySelectorAll(".autocomplete-item") + .forEach(function (item, index) { + item.classList.toggle("selected", index === selectedAutocompleteIndex); + }); + scrollToSelectedItem(); +} + +function scrollToSelectedItem() { + let selectedItem = autocompleteList + ? autocompleteList.querySelector(".autocomplete-item.selected") + : null; + if (selectedItem) + selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" }); +} + +function selectAutocompleteItem(index) { + if ( + index < 0 || + index >= autocompleteResults.length || + !chatInput || + autocompleteStartPos < 0 + ) + return; + let file = autocompleteResults[index]; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + + // Check if this is a context item (#terminal, #problems) + if (file.isContext && file.uri && file.uri.startsWith("context://")) { + // Remove the #query from input - chip will be added + chatInput.value = + value.substring(0, autocompleteStartPos) + value.substring(cursorPos); + let newCursorPos = autocompleteStartPos; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + + // Send context reference request to backend + vscode.postMessage({ + type: "selectContextReference", + contextType: file.name, // 'terminal' or 'problems' + options: undefined, + }); + + hideAutocomplete(); + chatInput.focus(); + autoResizeTextarea(); + updateInputHighlighter(); + saveWebviewState(); + updateSendButtonState(); + return; + } + + // Tool reference — insert #toolName, no file attachment needed + if (file.isTool) { + let referenceText = "#" + file.name + " "; + chatInput.value = + value.substring(0, autocompleteStartPos) + + referenceText + + value.substring(cursorPos); + let newCursorPos = autocompleteStartPos + referenceText.length; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + hideAutocomplete(); + chatInput.focus(); + autoResizeTextarea(); + updateInputHighlighter(); + saveWebviewState(); + updateSendButtonState(); + return; + } + + // Regular file/folder reference + let referenceText = "#" + file.name + " "; + chatInput.value = + value.substring(0, autocompleteStartPos) + + referenceText + + value.substring(cursorPos); + let newCursorPos = autocompleteStartPos + referenceText.length; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + vscode.postMessage({ type: "addFileReference", file: file }); + hideAutocomplete(); + chatInput.focus(); +} + +function syncAttachmentsWithText() { + let text = chatInput ? chatInput.value : ""; + let toRemove = []; + currentAttachments.forEach(function (att) { + // Skip temporary attachments (like pasted images) + if (att.isTemporary) return; + // Skip context attachments (#terminal, #problems) - they use context:// URI + if (att.uri && att.uri.startsWith("context://")) return; + // Only sync file references that have isTextReference flag + if (!att.isTextReference) return; + // Check if the #filename reference still exists in text + if (text.indexOf("#" + att.name) === -1) toRemove.push(att.id); + }); + if (toRemove.length > 0) { + toRemove.forEach(function (id) { + vscode.postMessage({ type: "removeAttachment", attachmentId: id }); + }); + currentAttachments = currentAttachments.filter(function (a) { + return toRemove.indexOf(a.id) === -1; + }); + updateChipsDisplay(); + } +} + +function handlePaste(event) { + if (!event.clipboardData) return; + let items = event.clipboardData.items; + for (var i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image/") === 0) { + event.preventDefault(); + let file = items[i].getAsFile(); + if (file) processImageFile(file); + return; + } + } +} + +/** + * Capture latest right-click position for context-menu copy resolution. + */ +function handleContextMenu(event) { + if (!event || !event.target || !event.target.closest) { + lastContextMenuTarget = null; + lastContextMenuTimestamp = 0; + return; + } + + lastContextMenuTarget = event.target; + lastContextMenuTimestamp = Date.now(); +} + +/** + * Override Copy when nothing is selected and context-menu target points to a message. + */ +function handleCopy(event) { + let selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.toString().length > 0) { + return; + } + + if ( + !lastContextMenuTarget || + Date.now() - lastContextMenuTimestamp > CONTEXT_MENU_COPY_MAX_AGE_MS + ) { + return; + } + + let copyText = resolveCopyTextFromTarget(lastContextMenuTarget); + if (!copyText) { + return; + } + + if (event) { + event.preventDefault(); + } + + if (event && event.clipboardData) { + try { + event.clipboardData.setData("text/plain", copyText); + lastContextMenuTarget = null; + lastContextMenuTimestamp = 0; + return; + } catch (error) { + // Fall through to extension host clipboard API fallback. + } + } + + vscode.postMessage({ type: "copyToClipboard", text: copyText }); + lastContextMenuTarget = null; + lastContextMenuTimestamp = 0; +} + +/** + * Resolve copy payload from the exact message area that was right-clicked. + */ +function resolveCopyTextFromTarget(target) { + if (!target || !target.closest) { + return ""; + } + + let pendingQuestion = target.closest(".pending-ai-question"); + if (pendingQuestion) { + if (pendingToolCall && typeof pendingToolCall.prompt === "string") { + return pendingToolCall.prompt; + } + return (pendingQuestion.textContent || "").trim(); + } + + let toolCallEntry = resolveToolCallEntryFromTarget(target); + if (!toolCallEntry) { + return ""; + } + + if (target.closest(".tool-call-ai-response")) { + return typeof toolCallEntry.prompt === "string" ? toolCallEntry.prompt : ""; + } + + if (target.closest(".tool-call-user-response")) { + return typeof toolCallEntry.response === "string" + ? toolCallEntry.response + : ""; + } + + if (target.closest(".chips-container")) { + return formatAttachmentsForCopy(toolCallEntry.attachments); + } + + return formatToolCallEntryForCopy(toolCallEntry); +} + +/** + * Resolve a tool call entry by traversing from a DOM target to its card id. + */ +function resolveToolCallEntryFromTarget(target) { + let card = target.closest(".tool-call-card"); + if (!card) { + return null; + } + + return resolveToolCallEntryFromCardId(card.getAttribute("data-id")); +} + +/** + * Find a tool call entry in current session first, then persisted history. + */ +function resolveToolCallEntryFromCardId(cardId) { + if (!cardId) { + return null; + } + + let currentEntry = currentSessionCalls.find(function (tc) { + return tc.id === cardId; + }); + if (currentEntry) { + return currentEntry; + } + + let persistedEntry = persistedHistory.find(function (tc) { + return tc.id === cardId; + }); + return persistedEntry || null; +} + +/** + * Compose full card copy output when right-click happened outside a specific message block. + */ +function formatToolCallEntryForCopy(entry) { + if (!entry) { + return ""; + } + + let parts = []; + if (typeof entry.prompt === "string" && entry.prompt.length > 0) { + parts.push(entry.prompt); + } + if (typeof entry.response === "string" && entry.response.length > 0) { + parts.push(entry.response); + } + + let attachmentsText = formatAttachmentsForCopy(entry.attachments); + if (attachmentsText) { + parts.push(attachmentsText); + } + + return parts.join("\n\n"); +} + +/** + * Convert attachment list to plain text while preserving stored attachment names. + */ +function formatAttachmentsForCopy(attachments) { + if (!attachments || attachments.length === 0) { + return ""; + } + + return attachments + .map(function (att) { + if (att && typeof att.name === "string" && att.name.length > 0) { + return att.name; + } + return att && typeof att.uri === "string" ? att.uri : ""; + }) + .filter(function (value) { + return value.length > 0; + }) + .join("\n"); +} + +function processImageFile(file) { + let reader = new FileReader(); + reader.onload = function (e) { + if (e.target && e.target.result) + vscode.postMessage({ + type: "saveImage", + data: e.target.result, + mimeType: file.type, + }); + }; + reader.readAsDataURL(file); +} + +function updateChipsDisplay() { + if (!chipsContainer) return; + if (currentAttachments.length === 0) { + chipsContainer.classList.add("hidden"); + chipsContainer.innerHTML = ""; + } else { + chipsContainer.classList.remove("hidden"); + chipsContainer.innerHTML = currentAttachments + .map(function (att) { + let isImage = + att.isTemporary || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(att.name); + let iconClass = att.isFolder + ? "folder" + : isImage + ? "file-media" + : "file"; + let displayName = att.isTemporary ? "Pasted Image" : att.name; + return ( + '
    ' + + '' + + '' + + escapeHtml(displayName) + + "" + + '
    ' + ); + }) + .join(""); + + chipsContainer.querySelectorAll(".chip-remove").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + const attId = btn.getAttribute("data-remove"); + if (attId) removeAttachment(attId); + }); + }); + } + // Persist attachments so they survive sidebar tab switches + saveWebviewState(); +} + +function removeAttachment(attachmentId) { + vscode.postMessage({ type: "removeAttachment", attachmentId: attachmentId }); + currentAttachments = currentAttachments.filter(function (a) { + return a.id !== attachmentId; + }); + updateChipsDisplay(); + // saveWebviewState() is called in updateChipsDisplay +} + +function escapeHtml(str) { + if (!str) return ""; + let div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +function renderAttachmentsHtml(attachments) { + if (!attachments || attachments.length === 0) return ""; + let items = attachments + .map(function (att) { + let iconClass = "file"; + if (att.isFolder) iconClass = "folder"; + else if ( + att.name && + (att.name.endsWith(".png") || + att.name.endsWith(".jpg") || + att.name.endsWith(".jpeg")) + ) + iconClass = "file-media"; + else if ((att.uri || "").indexOf("context://terminal") !== -1) + iconClass = "terminal"; + else if ((att.uri || "").indexOf("context://problems") !== -1) + iconClass = "error"; + + return ( + '
    ' + + '' + + '' + + escapeHtml(att.name) + + "" + + "
    " + ); + }) + .join(""); + + return ( + '
    ' + + items + + "
    " + ); +} diff --git a/tasksync-chat/src/webview-ui/history.js b/tasksync-chat/src/webview-ui/history.js new file mode 100644 index 0000000..9afc937 --- /dev/null +++ b/tasksync-chat/src/webview-ui/history.js @@ -0,0 +1,59 @@ +// ==================== History Modal ==================== + +function openHistoryModal() { + if (!historyModalOverlay) return; + // Request persisted history from extension + vscode.postMessage({ type: "openHistoryModal" }); + historyModalOverlay.classList.remove("hidden"); +} + +function closeHistoryModal() { + if (!historyModalOverlay) return; + historyModalOverlay.classList.add("hidden"); +} + +function clearAllPersistedHistory() { + if (persistedHistory.length === 0) return; + vscode.postMessage({ type: "clearPersistedHistory" }); + persistedHistory = []; + renderHistoryModal(); +} + +function initCardSelection() { + if (cardVibe) { + cardVibe.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + selectCard("normal", true); + }); + } + if (cardSpec) { + cardSpec.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + selectCard("queue", true); + }); + } + // Don't set default here - wait for updateQueue message from extension + // which contains the persisted enabled state + updateCardSelection(); +} + +function selectCard(card, notify) { + selectedCard = card; + queueEnabled = card === "queue"; + updateCardSelection(); + updateModeUI(); + updateQueueVisibility(); + + // Only notify extension if user clicked (not on init from persisted state) + if (notify) { + vscode.postMessage({ type: "toggleQueue", enabled: queueEnabled }); + } +} + +function updateCardSelection() { + // card-vibe = Normal mode, card-spec = Queue mode + if (cardVibe) cardVibe.classList.toggle("selected", !queueEnabled); + if (cardSpec) cardSpec.classList.toggle("selected", queueEnabled); +} diff --git a/tasksync-chat/src/webview-ui/init.js b/tasksync-chat/src/webview-ui/init.js new file mode 100644 index 0000000..ccd6441 --- /dev/null +++ b/tasksync-chat/src/webview-ui/init.js @@ -0,0 +1,614 @@ +function init() { + try { + cacheDOMElements(); + createHistoryModal(); + createEditModeUI(); + createApprovalModal(); + createSettingsModal(); + createNewSessionModal(); + bindEventListeners(); + unlockAudioOnInteraction(); // Enable audio after first user interaction + + // Remote mode: bind header buttons and hide VS Code-only UI + if (isRemoteMode) { + var newSessionBtn = document.getElementById("remote-new-session-btn"); + if (newSessionBtn) + newSessionBtn.addEventListener("click", function (e) { + e.stopPropagation(); + openNewSessionModal(); + }); + var settingsBtn = document.getElementById("remote-settings-btn"); + if (settingsBtn) + settingsBtn.addEventListener("click", function () { + openSettingsModal(); + }); + // Hide attach button (VS Code-only) + var attachBtn = document.getElementById("attach-btn"); + if (attachBtn) attachBtn.style.display = "none"; + } + renderQueue(); + updateModeUI(); + updateQueueVisibility(); + initCardSelection(); + + // Restore persisted input value (when user switches sidebar tabs and comes back) + if (chatInput && persistedInputValue) { + chatInput.value = persistedInputValue; + autoResizeTextarea(); + updateInputHighlighter(); + updateSendButtonState(); + } + + // Restore attachments display + if (currentAttachments.length > 0) { + updateChipsDisplay(); + } + + // Signal to extension that webview is ready to receive messages + // In remote mode, state comes via authSuccess after WebSocket connects — skip webviewReady + if (!isRemoteMode) { + vscode.postMessage({ type: "webviewReady" }); + } + } catch (err) { + console.error("[TaskSync] Init error:", err); + } +} + +/** + * Save webview state to persist across sidebar visibility changes + */ +function saveWebviewState() { + vscode.setState({ + inputValue: chatInput ? chatInput.value : "", + attachments: currentAttachments.filter(function (a) { + return !a.isTemporary; + }), // Don't persist temp images + }); +} + +function cacheDOMElements() { + chatInput = document.getElementById("chat-input"); + inputHighlighter = document.getElementById("input-highlighter"); + sendBtn = document.getElementById("send-btn"); + attachBtn = document.getElementById("attach-btn"); + modeBtn = document.getElementById("mode-btn"); + modeDropdown = document.getElementById("mode-dropdown"); + modeLabel = document.getElementById("mode-label"); + + queueSection = document.getElementById("queue-section"); + queueHeader = document.getElementById("queue-header"); + queueList = document.getElementById("queue-list"); + queueCount = document.getElementById("queue-count"); + chatContainer = document.getElementById("chat-container"); + chipsContainer = document.getElementById("chips-container"); + autocompleteDropdown = document.getElementById("autocomplete-dropdown"); + autocompleteList = document.getElementById("autocomplete-list"); + autocompleteEmpty = document.getElementById("autocomplete-empty"); + inputContainer = document.getElementById("input-container"); + inputAreaContainer = document.getElementById("input-area-container"); + welcomeSection = document.getElementById("welcome-section"); + cardVibe = document.getElementById("card-vibe"); + cardSpec = document.getElementById("card-spec"); + autopilotToggle = document.getElementById("autopilot-toggle"); + toolHistoryArea = document.getElementById("tool-history-area"); + chatStreamArea = document.getElementById("chat-stream-area"); + pendingMessage = document.getElementById("pending-message"); + // Slash command dropdown + slashDropdown = document.getElementById("slash-dropdown"); + slashList = document.getElementById("slash-list"); + slashEmpty = document.getElementById("slash-empty"); + // Get actions bar elements for edit mode + actionsBar = document.querySelector(".actions-bar"); + actionsLeft = document.querySelector(".actions-left"); +} + +function createHistoryModal() { + // Create modal overlay + historyModalOverlay = document.createElement("div"); + historyModalOverlay.className = "history-modal-overlay hidden"; + historyModalOverlay.id = "history-modal-overlay"; + + // Create modal container + historyModal = document.createElement("div"); + historyModal.className = "history-modal"; + historyModal.id = "history-modal"; + historyModal.setAttribute("role", "dialog"); + historyModal.setAttribute("aria-modal", "true"); + historyModal.setAttribute("aria-label", "Session History"); + + // Modal header + let modalHeader = document.createElement("div"); + modalHeader.className = "history-modal-header"; + + let titleSpan = document.createElement("span"); + titleSpan.className = "history-modal-title"; + titleSpan.textContent = "History"; + modalHeader.appendChild(titleSpan); + + // Info text - left aligned after title + let infoSpan = document.createElement("span"); + infoSpan.className = "history-modal-info"; + infoSpan.textContent = + "History is stored in VS Code globalStorage/tool-history.json"; + modalHeader.appendChild(infoSpan); + + // Clear all button (icon only) + historyModalClearAll = document.createElement("button"); + historyModalClearAll.className = "history-modal-clear-btn"; + historyModalClearAll.innerHTML = + ''; + historyModalClearAll.title = "Clear all history"; + modalHeader.appendChild(historyModalClearAll); + + // Close button + historyModalClose = document.createElement("button"); + historyModalClose.className = "history-modal-close-btn"; + historyModalClose.innerHTML = ''; + historyModalClose.title = "Close"; + modalHeader.appendChild(historyModalClose); + + // Modal body (list) + historyModalList = document.createElement("div"); + historyModalList.className = "history-modal-list"; + historyModalList.id = "history-modal-list"; + + // Assemble modal + historyModal.appendChild(modalHeader); + historyModal.appendChild(historyModalList); + historyModalOverlay.appendChild(historyModal); + + // Add to DOM + document.body.appendChild(historyModalOverlay); +} + +function createEditModeUI() { + // Create edit actions container (hidden by default) + editActionsContainer = document.createElement("div"); + editActionsContainer.className = "edit-actions-container hidden"; + editActionsContainer.id = "edit-actions-container"; + + // Edit mode label + let editLabel = document.createElement("span"); + editLabel.className = "edit-mode-label"; + editLabel.textContent = "Editing prompt"; + + // Cancel button (X) + editCancelBtn = document.createElement("button"); + editCancelBtn.className = "icon-btn edit-cancel-btn"; + editCancelBtn.title = "Cancel edit (Esc)"; + editCancelBtn.setAttribute("aria-label", "Cancel editing"); + editCancelBtn.innerHTML = ''; + + // Confirm button (✓) + editConfirmBtn = document.createElement("button"); + editConfirmBtn.className = "icon-btn edit-confirm-btn"; + editConfirmBtn.title = "Confirm edit (Enter)"; + editConfirmBtn.setAttribute("aria-label", "Confirm edit"); + editConfirmBtn.innerHTML = ''; + + // Assemble edit actions + editActionsContainer.appendChild(editLabel); + let btnGroup = document.createElement("div"); + btnGroup.className = "edit-btn-group"; + btnGroup.appendChild(editCancelBtn); + btnGroup.appendChild(editConfirmBtn); + editActionsContainer.appendChild(btnGroup); + + // Insert into actions bar (will be shown/hidden as needed) + if (actionsBar) { + actionsBar.appendChild(editActionsContainer); + } +} + +function createApprovalModal() { + // Create approval bar that appears at the top of input-wrapper (inside the border) + approvalModal = document.createElement("div"); + approvalModal.className = "approval-bar hidden"; + approvalModal.id = "approval-bar"; + approvalModal.setAttribute("role", "toolbar"); + approvalModal.setAttribute("aria-label", "Quick approval options"); + + // Left side label + let labelSpan = document.createElement("span"); + labelSpan.className = "approval-label"; + labelSpan.textContent = "Waiting on your input.."; + + // Right side buttons container + let buttonsContainer = document.createElement("div"); + buttonsContainer.className = "approval-buttons"; + + // No/Reject button (secondary action - text only) + approvalNoBtn = document.createElement("button"); + approvalNoBtn.className = "approval-btn approval-reject-btn"; + approvalNoBtn.setAttribute( + "aria-label", + "Reject and provide custom response", + ); + approvalNoBtn.textContent = "No"; + + // Continue/Accept button (primary action) + approvalContinueBtn = document.createElement("button"); + approvalContinueBtn.className = "approval-btn approval-accept-btn"; + approvalContinueBtn.setAttribute("aria-label", "Yes and continue"); + approvalContinueBtn.textContent = "Yes"; + + // Assemble buttons + buttonsContainer.appendChild(approvalNoBtn); + buttonsContainer.appendChild(approvalContinueBtn); + + // Assemble bar + approvalModal.appendChild(labelSpan); + approvalModal.appendChild(buttonsContainer); + + // Insert at top of input-wrapper (inside the border) + let inputWrapper = document.getElementById("input-wrapper"); + if (inputWrapper) { + inputWrapper.insertBefore(approvalModal, inputWrapper.firstChild); + } +} + +function createSettingsModal() { + // Create modal overlay + settingsModalOverlay = document.createElement("div"); + settingsModalOverlay.className = "settings-modal-overlay hidden"; + settingsModalOverlay.id = "settings-modal-overlay"; + + // Create modal container + settingsModal = document.createElement("div"); + settingsModal.className = "settings-modal"; + settingsModal.id = "settings-modal"; + settingsModal.setAttribute("role", "dialog"); + settingsModal.setAttribute("aria-labelledby", "settings-modal-title"); + + // Modal header + let modalHeader = document.createElement("div"); + modalHeader.className = "settings-modal-header"; + + let titleSpan = document.createElement("span"); + titleSpan.className = "settings-modal-title"; + titleSpan.id = "settings-modal-title"; + titleSpan.textContent = "Settings"; + modalHeader.appendChild(titleSpan); + + // Header buttons container + let headerButtons = document.createElement("div"); + headerButtons.className = "settings-modal-header-buttons"; + + // Report Issue button + let reportBtn = document.createElement("button"); + reportBtn.className = "settings-modal-header-btn"; + reportBtn.innerHTML = ''; + reportBtn.title = "Report Issue"; + reportBtn.setAttribute("aria-label", "Report an issue on GitHub"); + reportBtn.addEventListener("click", function () { + vscode.postMessage({ + type: "openExternal", + url: "https://github.com/4regab/TaskSync/issues/new", + }); + }); + headerButtons.appendChild(reportBtn); + + // Close button + settingsModalClose = document.createElement("button"); + settingsModalClose.className = "settings-modal-header-btn"; + settingsModalClose.innerHTML = ''; + settingsModalClose.title = "Close"; + settingsModalClose.setAttribute("aria-label", "Close settings"); + headerButtons.appendChild(settingsModalClose); + + modalHeader.appendChild(headerButtons); + + // Modal content + let modalContent = document.createElement("div"); + modalContent.className = "settings-modal-content"; + + // Sound section - simplified, toggle right next to header + let soundSection = document.createElement("div"); + soundSection.className = "settings-section"; + soundSection.innerHTML = + '
    ' + + '
    Notifications
    ' + + '
    ' + + "
    "; + modalContent.appendChild(soundSection); + + // Interactive approval section - toggle interactive Yes/No + choices UI + let approvalSection = document.createElement("div"); + approvalSection.className = "settings-section"; + approvalSection.innerHTML = + '
    ' + + '
    Interactive Approvals
    ' + + '
    ' + + "
    "; + modalContent.appendChild(approvalSection); + + // Send shortcut section - switch between Enter and Ctrl/Cmd+Enter send + let sendShortcutSection = document.createElement("div"); + sendShortcutSection.className = "settings-section"; + sendShortcutSection.innerHTML = + '
    ' + + '
    Ctrl/Cmd+Enter to Send
    ' + + '
    ' + + "
    "; + modalContent.appendChild(sendShortcutSection); + + // Autopilot section with cycling prompts list + let autopilotSection = document.createElement("div"); + autopilotSection.className = "settings-section"; + autopilotSection.innerHTML = + '
    ' + + '
    ' + + ' Autopilot Prompts' + + '' + + '' + + "
    " + + '' + + "
    " + + '
    ' + + '"; + modalContent.appendChild(autopilotSection); + + // Response Timeout section - dropdown for 10-120 minutes + let timeoutSection = document.createElement("div"); + timeoutSection.className = "settings-section"; + // Generate options from SSOT constant + let timeoutOptions = Array.from(RESPONSE_TIMEOUT_ALLOWED_VALUES) + .sort(function (a, b) { + return a - b; + }) + .map(function (val) { + let label = val === 0 ? "Disabled" : val + " minutes"; + if (val === RESPONSE_TIMEOUT_DEFAULT) label += " (default)"; + if (val >= 120 && val % 60 === 0) + label = val + " minutes (" + val / 60 + "h)"; + else if (val >= 90 && val % 30 === 0 && val !== 90) + label = val + " minutes (" + (val / 60).toFixed(1) + "h)"; + return '"; + }) + .join(""); + timeoutSection.innerHTML = + '
    ' + + '
    ' + + ' Response Timeout' + + '' + + '' + + "
    " + + "
    " + + '
    ' + + '" + + "
    "; + modalContent.appendChild(timeoutSection); + + // Session Warning section - warning threshold in hours + let sessionWarningSection = document.createElement("div"); + sessionWarningSection.className = "settings-section"; + sessionWarningSection.innerHTML = + '
    ' + + '
    ' + + ' Session Warning' + + '' + + '' + + "
    " + + "
    " + + '
    ' + + '" + + "
    "; + modalContent.appendChild(sessionWarningSection); + + // Max Consecutive Auto-Responses section - number input + let maxAutoSection = document.createElement("div"); + maxAutoSection.className = "settings-section"; + maxAutoSection.innerHTML = + '
    ' + + '
    ' + + ' Max Auto-Responses' + + '' + + '' + + "
    " + + "
    " + + '
    ' + + '' + + "
    "; + modalContent.appendChild(maxAutoSection); + + // Human-Like Delay section - toggle + min/max inputs + let humanDelaySection = document.createElement("div"); + humanDelaySection.className = "settings-section"; + humanDelaySection.innerHTML = + '
    ' + + '
    ' + + ' Human-Like Delay' + + '' + + '' + + "
    " + + '
    ' + + "
    " + + '
    ' + + '' + + '' + + '' + + '' + + "
    "; + modalContent.appendChild(humanDelaySection); + + // Reusable Prompts section - plus button next to title + let promptsSection = document.createElement("div"); + promptsSection.className = "settings-section"; + promptsSection.innerHTML = + '
    ' + + '
    Reusable Prompts
    ' + + '' + + "
    " + + '
    ' + + ''; + modalContent.appendChild(promptsSection); + + // Assemble modal + settingsModal.appendChild(modalHeader); + settingsModal.appendChild(modalContent); + settingsModalOverlay.appendChild(settingsModal); + + // Add to DOM + document.body.appendChild(settingsModalOverlay); + + // Cache inner elements + soundToggle = document.getElementById("sound-toggle"); + interactiveApprovalToggle = document.getElementById( + "interactive-approval-toggle", + ); + sendShortcutToggle = document.getElementById("send-shortcut-toggle"); + autopilotPromptsList = document.getElementById("autopilot-prompts-list"); + autopilotAddBtn = document.getElementById("autopilot-add-btn"); + addAutopilotPromptForm = document.getElementById("add-autopilot-prompt-form"); + autopilotPromptInput = document.getElementById("autopilot-prompt-input"); + saveAutopilotPromptBtn = document.getElementById("save-autopilot-prompt-btn"); + cancelAutopilotPromptBtn = document.getElementById( + "cancel-autopilot-prompt-btn", + ); + responseTimeoutSelect = document.getElementById("response-timeout-select"); + sessionWarningHoursSelect = document.getElementById( + "session-warning-hours-select", + ); + maxAutoResponsesInput = document.getElementById("max-auto-responses-input"); + humanDelayToggle = document.getElementById("human-delay-toggle"); + humanDelayRangeContainer = document.getElementById("human-delay-range"); + humanDelayMinInput = document.getElementById("human-delay-min-input"); + humanDelayMaxInput = document.getElementById("human-delay-max-input"); + promptsList = document.getElementById("prompts-list"); + addPromptBtn = document.getElementById("add-prompt-btn"); + addPromptForm = document.getElementById("add-prompt-form"); +} + +// ===== NEW SESSION MODAL ===== + +var newSessionModalOverlay = null; + +function createNewSessionModal() { + newSessionModalOverlay = document.createElement("div"); + newSessionModalOverlay.className = "settings-modal-overlay hidden"; + newSessionModalOverlay.id = "new-session-modal-overlay"; + + var modal = document.createElement("div"); + modal.className = "settings-modal new-session-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-labelledby", "new-session-modal-title"); + + // Header + var header = document.createElement("div"); + header.className = "settings-modal-header"; + var title = document.createElement("span"); + title.className = "settings-modal-title"; + title.id = "new-session-modal-title"; + title.textContent = "New Session"; + header.appendChild(title); + var headerBtns = document.createElement("div"); + headerBtns.className = "settings-modal-header-buttons"; + var closeBtn = document.createElement("button"); + closeBtn.className = "settings-modal-header-btn"; + closeBtn.innerHTML = ''; + closeBtn.title = "Cancel"; + closeBtn.setAttribute("aria-label", "Cancel"); + closeBtn.addEventListener("click", closeNewSessionModal); + headerBtns.appendChild(closeBtn); + header.appendChild(headerBtns); + + // Content + var content = document.createElement("div"); + content.className = "settings-modal-content new-session-modal-content"; + + // Model note + var modelNote = document.createElement("p"); + modelNote.className = "new-session-note"; + modelNote.innerHTML = + ' Please check the model preselected in VS Code\'s Agent Mode before starting.'; + content.appendChild(modelNote); + + // Warning message + var warning = document.createElement("p"); + warning.className = "new-session-warning"; + warning.textContent = + "This will clear the current session history and start fresh."; + content.appendChild(warning); + + // Button row + var btnRow = document.createElement("div"); + btnRow.className = "new-session-btn-row"; + var cancelBtn = document.createElement("button"); + cancelBtn.className = "form-btn form-btn-cancel"; + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", closeNewSessionModal); + btnRow.appendChild(cancelBtn); + + var confirmBtn = document.createElement("button"); + confirmBtn.className = "form-btn form-btn-save"; + confirmBtn.textContent = "New Session"; + confirmBtn.addEventListener("click", function () { + closeNewSessionModal(); + vscode.postMessage({ type: "newSession" }); + }); + btnRow.appendChild(confirmBtn); + content.appendChild(btnRow); + + modal.appendChild(header); + modal.appendChild(content); + newSessionModalOverlay.appendChild(modal); + document.body.appendChild(newSessionModalOverlay); + + // Close on overlay click + newSessionModalOverlay.addEventListener("click", function (e) { + if (e.target === newSessionModalOverlay) closeNewSessionModal(); + }); +} + +function openNewSessionModal() { + if (!newSessionModalOverlay) return; + newSessionModalOverlay.classList.remove("hidden"); +} + +function closeNewSessionModal() { + if (!newSessionModalOverlay) return; + newSessionModalOverlay.classList.add("hidden"); +} diff --git a/tasksync-chat/src/webview-ui/input.js b/tasksync-chat/src/webview-ui/input.js new file mode 100644 index 0000000..79a955f --- /dev/null +++ b/tasksync-chat/src/webview-ui/input.js @@ -0,0 +1,420 @@ +// ==================== Input Handling ==================== + +function autoResizeTextarea() { + if (!chatInput) return; + chatInput.style.height = "auto"; + chatInput.style.height = Math.min(chatInput.scrollHeight, 150) + "px"; +} + +/** + * Update the input highlighter overlay to show syntax highlighting + * for slash commands (/command) and file references (#file) + */ +function updateInputHighlighter() { + if (!inputHighlighter || !chatInput) return; + + let text = chatInput.value; + if (!text) { + inputHighlighter.innerHTML = ""; + return; + } + + // Build a list of known slash command names for exact matching + let knownSlashNames = reusablePrompts.map(function (p) { + return p.name; + }); + // Also add any pending stored mappings + let mappings = chatInput._slashPrompts || {}; + Object.keys(mappings).forEach(function (name) { + if (knownSlashNames.indexOf(name) === -1) knownSlashNames.push(name); + }); + + // Escape HTML first + let html = escapeHtml(text); + + // Highlight slash commands - match /word patterns + // Only highlight if it's a known command OR any /word pattern + html = html.replace( + /(^|\s)(\/[a-zA-Z0-9_-]+)(\s|$)/g, + function (match, before, slash, after) { + let cmdName = slash.substring(1); // Remove the / + // Highlight if it's a known command or if we have prompts defined + if ( + knownSlashNames.length === 0 || + knownSlashNames.indexOf(cmdName) >= 0 + ) { + return ( + before + '' + slash + "" + after + ); + } + // Still highlight as generic slash command + return ( + before + '' + slash + "" + after + ); + }, + ); + + // Highlight file references - match #word patterns + html = html.replace( + /(^|\s)(#[a-zA-Z0-9_.\/-]+)(\s|$)/g, + function (match, before, hash, after) { + return ( + before + '' + hash + "" + after + ); + }, + ); + + // Don't add trailing space - causes visual artifacts + // html += ' '; + + inputHighlighter.innerHTML = html; + + // Sync scroll position + inputHighlighter.scrollTop = chatInput.scrollTop; +} + +function handleTextareaInput() { + autoResizeTextarea(); + updateInputHighlighter(); + handleAutocomplete(); + handleSlashCommands(); + // Context items (#terminal, #problems) now handled via handleAutocomplete() + syncAttachmentsWithText(); + updateSendButtonState(); + // Persist input value so it survives sidebar tab switches + saveWebviewState(); +} + +function updateSendButtonState() { + if (!sendBtn || !chatInput) return; + let hasText = chatInput.value.trim().length > 0; + sendBtn.classList.toggle("has-text", hasText); +} + +function handleTextareaKeydown(e) { + // Handle approval modal keyboard shortcuts when visible + if ( + isApprovalQuestion && + approvalModal && + !approvalModal.classList.contains("hidden") + ) { + // Enter sends "Continue" when approval modal is visible and input is empty + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + let inputText = chatInput ? chatInput.value.trim() : ""; + if (!inputText) { + e.preventDefault(); + handleApprovalContinue(); + return; + } + // If there's text, fall through to normal send behavior + } + // Escape dismisses approval modal + if (e.key === "Escape") { + e.preventDefault(); + handleApprovalNo(); + return; + } + } + + // Handle edit mode keyboard shortcuts + if (editingPromptId) { + if (e.key === "Escape") { + e.preventDefault(); + cancelEditMode(); + return; + } + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + confirmEditMode(); + return; + } + // Allow other keys in edit mode + return; + } + + // Handle slash command dropdown navigation + if (slashDropdownVisible) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (selectedSlashIndex < slashResults.length - 1) { + selectedSlashIndex++; + updateSlashSelection(); + } + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (selectedSlashIndex > 0) { + selectedSlashIndex--; + updateSlashSelection(); + } + return; + } + if ((e.key === "Enter" || e.key === "Tab") && selectedSlashIndex >= 0) { + e.preventDefault(); + selectSlashItem(selectedSlashIndex); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + hideSlashDropdown(); + return; + } + } + + if (autocompleteVisible) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (selectedAutocompleteIndex < autocompleteResults.length - 1) { + selectedAutocompleteIndex++; + updateAutocompleteSelection(); + } + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (selectedAutocompleteIndex > 0) { + selectedAutocompleteIndex--; + updateAutocompleteSelection(); + } + return; + } + if ( + (e.key === "Enter" || e.key === "Tab") && + selectedAutocompleteIndex >= 0 + ) { + e.preventDefault(); + selectAutocompleteItem(selectedAutocompleteIndex); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + hideAutocomplete(); + return; + } + } + + // Context dropdown navigation removed - context now uses # via file autocomplete + + let isPlainEnter = + e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey; + let isCtrlOrCmdEnter = + e.key === "Enter" && !e.shiftKey && (e.ctrlKey || e.metaKey); + + if (!sendWithCtrlEnter && isPlainEnter) { + e.preventDefault(); + handleSend(); + return; + } + + if (sendWithCtrlEnter && isCtrlOrCmdEnter) { + e.preventDefault(); + handleSend(); + return; + } +} + +/** + * Handle send action triggered by VS Code command/keybinding. + * Mirrors Enter behavior while avoiding sends when input is not focused. + */ +function handleSendFromShortcut() { + if (!chatInput || document.activeElement !== chatInput) { + return; + } + + if ( + isApprovalQuestion && + approvalModal && + !approvalModal.classList.contains("hidden") + ) { + let inputText = chatInput.value.trim(); + if (!inputText) { + handleApprovalContinue(); + return; + } + } + + if (editingPromptId) { + confirmEditMode(); + return; + } + + if (slashDropdownVisible && selectedSlashIndex >= 0) { + selectSlashItem(selectedSlashIndex); + return; + } + + if (autocompleteVisible && selectedAutocompleteIndex >= 0) { + selectAutocompleteItem(selectedAutocompleteIndex); + return; + } + + handleSend(); +} + +function handleSend() { + let text = chatInput ? chatInput.value.trim() : ""; + if (!text && currentAttachments.length === 0) { + // If choices are selected and input is empty, send the selected choices + let choicesBar = document.getElementById("choices-bar"); + if (choicesBar && !choicesBar.classList.contains("hidden")) { + let selectedButtons = choicesBar.querySelectorAll(".choice-btn.selected"); + if (selectedButtons.length > 0) { + handleChoicesSend(); + return; + } + } + return; + } + + // Expand slash commands to full prompt text + text = expandSlashCommands(text); + + // Hide approval modal when sending any response + hideApprovalModal(); + + // If processing response (AI working), auto-queue the message + if (isProcessingResponse && text) { + addToQueue(text); + // This reduces friction - user's prompt is in queue, so show them queue mode + if (!queueEnabled) { + queueEnabled = true; + updateModeUI(); + updateQueueVisibility(); + updateCardSelection(); + vscode.postMessage({ type: "toggleQueue", enabled: true }); + } + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + // Clear persisted state after sending + saveWebviewState(); + return; + } + + // In remote mode with no pending tool call and no active session, + // bypass queue mode and use direct chat for immediate response. + var bypassQueueForChat = + isRemoteMode && + !pendingToolCall && + text && + currentSessionCalls.length === 0; + debugLog( + "handleSend: isRemote:", + isRemoteMode, + "bypass:", + bypassQueueForChat, + "queue:", + queueEnabled, + "sessions:", + currentSessionCalls.length, + ); + + if (!bypassQueueForChat && queueEnabled && text && !pendingToolCall) { + debugLog("handleSend: → addToQueue"); + addToQueue(text); + } else if (isRemoteMode && !pendingToolCall && text) { + debugLog("handleSend: → chatMessage"); + addChatStreamUserBubble(text); + vscode.postMessage({ type: "chatMessage", content: text }); + } else { + vscode.postMessage({ + type: "submit", + value: text, + attachments: currentAttachments, + }); + } + + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + // Clear persisted state after sending + saveWebviewState(); +} + +function handleAttach() { + vscode.postMessage({ type: "addAttachment" }); +} + +function toggleModeDropdown(e) { + e.stopPropagation(); + if (dropdownOpen) closeModeDropdown(); + else { + dropdownOpen = true; + positionModeDropdown(); + modeDropdown.classList.remove("hidden"); + modeDropdown.classList.add("visible"); + } +} + +function positionModeDropdown() { + if (!modeDropdown || !modeBtn) return; + let rect = modeBtn.getBoundingClientRect(); + modeDropdown.style.bottom = window.innerHeight - rect.top + 4 + "px"; + modeDropdown.style.left = rect.left + "px"; +} + +function closeModeDropdown() { + dropdownOpen = false; + if (modeDropdown) { + modeDropdown.classList.remove("visible"); + modeDropdown.classList.add("hidden"); + } +} + +function setMode(mode, notify) { + queueEnabled = mode === "queue"; + updateModeUI(); + updateQueueVisibility(); + updateCardSelection(); + if (notify) + vscode.postMessage({ type: "toggleQueue", enabled: queueEnabled }); +} + +function updateModeUI() { + if (modeLabel) modeLabel.textContent = queueEnabled ? "Queue" : "Normal"; + document.querySelectorAll(".mode-option[data-mode]").forEach(function (opt) { + opt.classList.toggle( + "selected", + opt.getAttribute("data-mode") === (queueEnabled ? "queue" : "normal"), + ); + }); +} + +function updateQueueVisibility() { + if (!queueSection) return; + // Hide queue section if: not in queue mode OR queue is empty + let shouldHide = !queueEnabled || promptQueue.length === 0; + let wasHidden = queueSection.classList.contains("hidden"); + queueSection.classList.toggle("hidden", shouldHide); + // Only collapse when showing for the FIRST time (was hidden, now visible) + // Don't collapse on subsequent updates to preserve user's expanded state + if (wasHidden && !shouldHide && promptQueue.length > 0) { + queueSection.classList.add("collapsed"); + } +} + +function handleQueueHeaderClick() { + if (queueSection) queueSection.classList.toggle("collapsed"); +} + +function normalizeResponseTimeout(value) { + if (!Number.isFinite(value)) { + return RESPONSE_TIMEOUT_DEFAULT; + } + if (!RESPONSE_TIMEOUT_ALLOWED_VALUES.has(value)) { + return RESPONSE_TIMEOUT_DEFAULT; + } + return value; +} diff --git a/tasksync-chat/src/webview-ui/markdownUtils.js b/tasksync-chat/src/webview-ui/markdownUtils.js new file mode 100644 index 0000000..7b0fed2 --- /dev/null +++ b/tasksync-chat/src/webview-ui/markdownUtils.js @@ -0,0 +1,163 @@ +// ==================== Markdown Utilities ==================== +// Extracted from rendering.js: table processing and list conversion + +// Constants for security and performance limits +let MARKDOWN_MAX_LENGTH = 100000; // Max markdown input length to prevent ReDoS +let MAX_TABLE_ROWS = 100; // Max table rows to process + +/** + * Process a buffer of table lines into HTML table markup (ReDoS-safe). + * Security: Caller (formatMarkdown) must pre-escape HTML before passing lines here. + */ +function processTableBuffer(lines, maxRows) { + if (lines.length < 2) return lines.join("\n"); + if (lines.length > maxRows) return lines.join("\n"); // Skip very large tables + + // Check if second line is separator (contains only |, -, :, spaces) + let separatorRegex = /^\|[\s\-:|]+\|$/; + if (!separatorRegex.test(lines[1].trim())) return lines.join("\n"); + + let headerCells = lines[0].split("|").filter(function (c) { + return c.trim() !== ""; + }); + if (headerCells.length === 0) return lines.join("\n"); + + let headerHtml = + "" + + headerCells + .map(function (c) { + return "" + c.trim() + ""; + }) + .join("") + + ""; + + let bodyHtml = ""; + for (var i = 2; i < lines.length; i++) { + if (!lines[i].trim()) continue; + let cells = lines[i].split("|").filter(function (c) { + return c.trim() !== ""; + }); + bodyHtml += + "" + + cells + .map(function (c) { + return "" + c.trim() + ""; + }) + .join("") + + ""; + } + + return ( + '' + + headerHtml + + "" + + bodyHtml + + "
    " + ); +} + +/** + * Converts markdown lists (ordered/unordered) with indentation-based nesting into HTML. + * Uses 2-space indentation as one nesting level. + * @param {string} text - Escaped markdown text (must already be HTML-escaped by caller) + * @returns {string} Text with markdown lists converted to nested HTML lists + */ +function convertMarkdownLists(text) { + let listLineRegex = /^\s*(?:[-*]|\d+\.)\s.*$/; + let lines = text.split("\n"); + let output = []; + let listBuffer = []; + + function renderListNode(node) { + let startAttr = + node.type === "ol" && typeof node.start === "number" && node.start > 1 + ? ' start="' + node.start + '"' + : ""; + return ( + "<" + + node.type + + startAttr + + ">" + + node.items + .map(function (item) { + let childrenHtml = item.children.map(renderListNode).join(""); + return "
  • " + item.text + childrenHtml + "
  • "; + }) + .join("") + + "" + ); + } + + function processListBuffer(buffer) { + let listItemRegex = /^(\s*)([-*]|\d+\.)\s+(.*)$/; + let rootLists = []; + let stack = []; + + buffer.forEach(function (line) { + let match = listItemRegex.exec(line); + if (!match) return; + + let indent = match[1].replace(/\t/g, " ").length; + let depth = Math.floor(indent / 2); + let marker = match[2]; + let type = marker === "-" || marker === "*" ? "ul" : "ol"; + let text = match[3]; + + while (stack.length > depth + 1) { + stack.pop(); + } + + let entry = stack[depth]; + if (!entry || entry.type !== type) { + const listNode = { + type: type, + items: [], + start: type === "ol" ? parseInt(marker, 10) : null, + }; + + if (depth === 0) { + rootLists.push(listNode); + } else { + const parentEntry = stack[depth - 1]; + if (parentEntry && parentEntry.lastItem) { + parentEntry.lastItem.children.push(listNode); + } else { + rootLists.push(listNode); + } + } + + entry = { type: type, list: listNode, lastItem: null }; + } + + stack = stack.slice(0, depth); + stack[depth] = entry; + + let item = { text: text, children: [] }; + entry.list.items.push(item); + entry.lastItem = item; + stack[depth] = entry; + }); + + return rootLists.map(renderListNode).join(""); + } + + lines.forEach(function (line) { + if (listLineRegex.test(line)) { + listBuffer.push(line); + return; + } + if (listBuffer.length > 0) { + output.push(processListBuffer(listBuffer)); + listBuffer = []; + } + output.push(line); + }); + + if (listBuffer.length > 0) { + output.push(processListBuffer(listBuffer)); + } + + return output.join("\n"); +} diff --git a/tasksync-chat/src/webview-ui/messageHandler.js b/tasksync-chat/src/webview-ui/messageHandler.js new file mode 100644 index 0000000..3bcb75e --- /dev/null +++ b/tasksync-chat/src/webview-ui/messageHandler.js @@ -0,0 +1,213 @@ +// ==================== Extension Message Handler ==================== + +function handleExtensionMessage(event) { + let message = event.data; + switch (message.type) { + case "updateQueue": + promptQueue = message.queue || []; + queueEnabled = message.enabled !== false; + renderQueue(); + updateModeUI(); + updateQueueVisibility(); + updateCardSelection(); + // Hide welcome section if we have current session calls + updateWelcomeSectionVisibility(); + break; + case "toolCallPending": + showPendingToolCall( + message.id, + message.prompt, + message.isApproval, + message.choices, + message.summary, + ); + break; + case "toolCallCompleted": + addToolCallToCurrentSession(message.entry, message.sessionTerminated); + break; + case "updateCurrentSession": + currentSessionCalls = message.history || []; + renderCurrentSession(); + // Hide welcome section if we have completed tool calls + updateWelcomeSectionVisibility(); + // Auto-scroll to bottom after rendering + scrollToBottom(); + break; + case "updatePersistedHistory": + persistedHistory = message.history || []; + renderHistoryModal(); + break; + case "openHistoryModal": + openHistoryModal(); + break; + case "openSettingsModal": + openSettingsModal(); + break; + case "updateSettings": + soundEnabled = message.soundEnabled !== false; + interactiveApprovalEnabled = message.interactiveApprovalEnabled !== false; + sendWithCtrlEnter = message.sendWithCtrlEnter === true; + autopilotEnabled = message.autopilotEnabled === true; + autopilotText = + typeof message.autopilotText === "string" ? message.autopilotText : ""; + autopilotPrompts = Array.isArray(message.autopilotPrompts) + ? message.autopilotPrompts + : []; + reusablePrompts = message.reusablePrompts || []; + responseTimeout = normalizeResponseTimeout(message.responseTimeout); + sessionWarningHours = + typeof message.sessionWarningHours === "number" + ? message.sessionWarningHours + : DEFAULT_SESSION_WARNING_HOURS; + maxConsecutiveAutoResponses = + typeof message.maxConsecutiveAutoResponses === "number" + ? message.maxConsecutiveAutoResponses + : DEFAULT_MAX_AUTO_RESPONSES; + humanLikeDelayEnabled = message.humanLikeDelayEnabled !== false; + humanLikeDelayMin = + typeof message.humanLikeDelayMin === "number" + ? message.humanLikeDelayMin + : DEFAULT_HUMAN_DELAY_MIN; + humanLikeDelayMax = + typeof message.humanLikeDelayMax === "number" + ? message.humanLikeDelayMax + : DEFAULT_HUMAN_DELAY_MAX; + updateSoundToggleUI(); + updateInteractiveApprovalToggleUI(); + updateSendWithCtrlEnterToggleUI(); + updateAutopilotToggleUI(); + renderAutopilotPromptsList(); + updateResponseTimeoutUI(); + updateSessionWarningHoursUI(); + updateMaxAutoResponsesUI(); + updateHumanDelayUI(); + renderPromptsList(); + break; + case "slashCommandResults": + showSlashDropdown(message.prompts || []); + break; + case "playNotificationSound": + playNotificationSound(); + break; + case "fileSearchResults": + showAutocomplete(message.files || []); + break; + case "updateAttachments": + currentAttachments = message.attachments || []; + updateChipsDisplay(); + break; + case "imageSaved": + if ( + message.attachment && + !currentAttachments.some(function (a) { + return a.id === message.attachment.id; + }) + ) { + currentAttachments.push(message.attachment); + updateChipsDisplay(); + } + break; + case "clear": + console.log("[TaskSync Webview] clear — resetting session state"); + promptQueue = []; + currentSessionCalls = []; + pendingToolCall = null; + isProcessingResponse = false; + renderQueue(); + renderCurrentSession(); + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + pendingMessage.innerHTML = + '
    ' + + ' New session started — waiting for AI' + + "
    "; + } + updateWelcomeSectionVisibility(); + break; + case "updateSessionTimer": + // Timer is displayed in the view title bar by the extension host + // No webview UI to update + break; + case "triggerSendFromShortcut": + handleSendFromShortcut(); + break; + } +} + +function showPendingToolCall(id, prompt, isApproval, choices, summary) { + console.log( + "[TaskSync Webview] showPendingToolCall — id:", + id, + "hasSummary:", + !!summary, + "summaryLength:", + summary ? summary.length : 0, + "promptLength:", + prompt ? prompt.length : 0, + ); + pendingToolCall = { id: id, prompt: prompt, summary: summary || "" }; + isProcessingResponse = false; // AI is now asking, not processing + isApprovalQuestion = isApproval === true; + currentChoices = choices || []; + + if (welcomeSection) { + welcomeSection.classList.add("hidden"); + } + + // Add pending class to disable session switching UI + document.body.classList.add("has-pending-toolcall"); + + // Show AI question (and summary if present) as rendered markdown + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + let pendingHtml = ""; + if (summary) { + console.log( + "[TaskSync Webview] Rendering summary in pending view — length:", + summary.length, + "preview:", + summary.slice(0, 80), + ); + pendingHtml += + '
    ' + formatMarkdown(summary) + "
    "; + } else { + console.log("[TaskSync Webview] No summary to render in pending view"); + } + pendingHtml += + '
    ' + formatMarkdown(prompt) + "
    "; + console.log( + "[TaskSync Webview] Pending HTML set — totalLength:", + pendingHtml.length, + ); + pendingMessage.innerHTML = pendingHtml; + } else { + console.error("[TaskSync Webview] pendingMessage element is null!"); + } + + // Re-render current session (without the pending item - it's shown separately) + renderCurrentSession(); + // Render any mermaid diagrams in pending message + renderMermaidDiagrams(); + // Auto-scroll to show the new pending message + scrollToBottom(); + + // Show choice buttons if we have choices, otherwise show approval modal for yes/no questions + // Only show if interactive approval is enabled + if (interactiveApprovalEnabled) { + if (currentChoices.length > 0) { + showChoicesBar(); + } else if (isApprovalQuestion) { + showApprovalModal(); + } else { + hideApprovalModal(); + hideChoicesBar(); + } + } else { + // Interactive approval disabled - just focus input for manual typing + hideApprovalModal(); + hideChoicesBar(); + if (chatInput) { + chatInput.focus(); + } + } +} diff --git a/tasksync-chat/src/webview-ui/queue.js b/tasksync-chat/src/webview-ui/queue.js new file mode 100644 index 0000000..aaafb5c --- /dev/null +++ b/tasksync-chat/src/webview-ui/queue.js @@ -0,0 +1,289 @@ +// ==================== Queue Management ==================== + +function addToQueue(prompt) { + if (!prompt || !prompt.trim()) return; + // ID format must match VALID_QUEUE_ID_PATTERN in remoteConstants.ts + let id = + "q_" + Date.now() + "_" + Math.random().toString(36).substring(2, 11); + // Store attachments with the queue item + let attachmentsToStore = + currentAttachments.length > 0 ? currentAttachments.slice() : undefined; + promptQueue.push({ + id: id, + prompt: prompt.trim(), + attachments: attachmentsToStore, + }); + renderQueue(); + // Expand queue section when adding items so user can see what was added + if (queueSection) queueSection.classList.remove("collapsed"); + // Send to backend with attachments + vscode.postMessage({ + type: "addQueuePrompt", + prompt: prompt.trim(), + id: id, + attachments: attachmentsToStore || [], + }); + // Clear attachments after adding to queue (they're now stored with the queue item) + currentAttachments = []; + updateChipsDisplay(); +} + +function removeFromQueue(id) { + promptQueue = promptQueue.filter(function (item) { + return item.id !== id; + }); + renderQueue(); + vscode.postMessage({ type: "removeQueuePrompt", promptId: id }); +} + +function renderQueue() { + if (!queueList) return; + if (queueCount) queueCount.textContent = promptQueue.length; + + // Update visibility based on queue state + updateQueueVisibility(); + + if (promptQueue.length === 0) { + queueList.innerHTML = '
    No prompts in queue
    '; + return; + } + + queueList.innerHTML = promptQueue + .map(function (item, index) { + let bulletClass = index === 0 ? "active" : "pending"; + let truncatedPrompt = + item.prompt.length > 80 + ? item.prompt.substring(0, 80) + "..." + : item.prompt; + // Show attachment indicator if this queue item has attachments + let attachmentBadge = + item.attachments && item.attachments.length > 0 + ? '' + : ""; + return ( + '
    ' + + '' + + '' + + (index + 1) + + ". " + + escapeHtml(truncatedPrompt) + + "" + + attachmentBadge + + '
    ' + + '' + + '' + + "
    " + ); + }) + .join(""); + + queueList.querySelectorAll(".remove-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + let id = btn.getAttribute("data-id"); + if (id) removeFromQueue(id); + }); + }); + + queueList.querySelectorAll(".edit-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + let id = btn.getAttribute("data-id"); + if (id) startEditPrompt(id); + }); + }); + + bindDragAndDrop(); + bindKeyboardNavigation(); +} + +function startEditPrompt(id) { + // Cancel any existing edit first + if (editingPromptId && editingPromptId !== id) { + cancelEditMode(); + } + + let item = promptQueue.find(function (p) { + return p.id === id; + }); + if (!item) return; + + // Save current state + editingPromptId = id; + editingOriginalPrompt = item.prompt; + savedInputValue = chatInput ? chatInput.value : ""; + + // Mark queue item as being edited + let queueItem = queueList.querySelector('.queue-item[data-id="' + id + '"]'); + if (queueItem) { + queueItem.classList.add("editing"); + } + + // Switch to edit mode UI + enterEditMode(item.prompt); +} + +function enterEditMode(promptText) { + // Hide normal actions, show edit actions + if (actionsLeft) actionsLeft.classList.add("hidden"); + if (sendBtn) sendBtn.classList.add("hidden"); + if (editActionsContainer) editActionsContainer.classList.remove("hidden"); + + // Mark input container as in edit mode + if (inputContainer) { + inputContainer.classList.add("edit-mode"); + inputContainer.setAttribute("aria-label", "Editing queue prompt"); + } + + // Set input value to the prompt being edited + if (chatInput) { + chatInput.value = promptText; + chatInput.setAttribute( + "aria-label", + "Edit prompt text. Press Enter to confirm, Escape to cancel.", + ); + chatInput.focus(); + // Move cursor to end + chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length); + autoResizeTextarea(); + } +} + +function exitEditMode() { + // Show normal actions, hide edit actions + if (actionsLeft) actionsLeft.classList.remove("hidden"); + if (sendBtn) sendBtn.classList.remove("hidden"); + if (editActionsContainer) editActionsContainer.classList.add("hidden"); + + // Remove edit mode class from input container + if (inputContainer) { + inputContainer.classList.remove("edit-mode"); + inputContainer.removeAttribute("aria-label"); + } + + // Remove editing class from queue item + if (queueList) { + let editingItem = queueList.querySelector(".queue-item.editing"); + if (editingItem) editingItem.classList.remove("editing"); + } + + // Restore original input value and accessibility + if (chatInput) { + chatInput.value = savedInputValue; + chatInput.setAttribute("aria-label", "Message input"); + autoResizeTextarea(); + } + + // Reset edit state + editingPromptId = null; + editingOriginalPrompt = null; + savedInputValue = ""; +} + +function confirmEditMode() { + if (!editingPromptId) return; + + let newValue = chatInput ? chatInput.value.trim() : ""; + + if (!newValue) { + // If empty, remove the prompt + removeFromQueue(editingPromptId); + } else if (newValue !== editingOriginalPrompt) { + // Update the prompt + let item = promptQueue.find(function (p) { + return p.id === editingPromptId; + }); + if (item) { + item.prompt = newValue; + vscode.postMessage({ + type: "editQueuePrompt", + promptId: editingPromptId, + newPrompt: newValue, + }); + } + } + + // Clear saved input - we don't want to restore old value after editing + savedInputValue = ""; + + exitEditMode(); + renderQueue(); +} + +function cancelEditMode() { + exitEditMode(); + renderQueue(); +} + +/** + * Handle "accept" button click in approval modal + * Sends "yes" as the response + */ +function handleApprovalContinue() { + if (!pendingToolCall) return; + + // Hide approval modal + hideApprovalModal(); + + // Send affirmative response + vscode.postMessage({ type: "submit", value: "yes", attachments: [] }); + if (chatInput) { + chatInput.value = ""; + chatInput.style.height = "auto"; + updateInputHighlighter(); + } + currentAttachments = []; + updateChipsDisplay(); + updateSendButtonState(); + saveWebviewState(); +} + +/** + * Handle "No" button click in approval modal + * Dismisses modal and focuses input for custom response + */ +function handleApprovalNo() { + // Hide approval modal but keep pending state + hideApprovalModal(); + + // Focus input for custom response + if (chatInput) { + chatInput.focus(); + // Optionally pre-fill with "No, " to help user + if (!chatInput.value.trim()) { + chatInput.value = "No, "; + chatInput.setSelectionRange( + chatInput.value.length, + chatInput.value.length, + ); + } + autoResizeTextarea(); + updateInputHighlighter(); + updateSendButtonState(); + saveWebviewState(); + } +} diff --git a/tasksync-chat/src/webview-ui/rendering.js b/tasksync-chat/src/webview-ui/rendering.js new file mode 100644 index 0000000..756f1da --- /dev/null +++ b/tasksync-chat/src/webview-ui/rendering.js @@ -0,0 +1,554 @@ +// ==================== Tool Call Rendering ==================== + +function addToolCallToCurrentSession(entry, sessionTerminated) { + pendingToolCall = null; + document.body.classList.remove("has-pending-toolcall"); + hideApprovalModal(); + hideChoicesBar(); + + let idx = currentSessionCalls.findIndex(function (tc) { + return tc.id === entry.id; + }); + if (idx >= 0) { + currentSessionCalls[idx] = entry; + } else { + currentSessionCalls.unshift(entry); + } + renderCurrentSession(); + isProcessingResponse = true; + if (pendingMessage) { + pendingMessage.classList.remove("hidden"); + // Check if session terminated + if (sessionTerminated) { + isProcessingResponse = false; + pendingMessage.innerHTML = + '
    ' + + "Session terminated" + + '
    "; + let newSessionBtn = document.getElementById("new-session-btn"); + if (newSessionBtn) { + newSessionBtn.addEventListener("click", function () { + vscode.postMessage({ type: "newSession" }); + }); + } + } else { + pendingMessage.innerHTML = + '
    Processing your response
    '; + } + } + + // Auto-scroll to show the working indicator + scrollToBottom(); +} + +function renderCurrentSession() { + if (!toolHistoryArea) return; + + let completedCalls = currentSessionCalls.filter(function (tc) { + return tc.status === "completed"; + }); + + if (completedCalls.length === 0) { + toolHistoryArea.innerHTML = ""; + return; + } + + // Reverse to show oldest first (new items stack at bottom) + let sortedCalls = completedCalls.slice().reverse(); + + let cardsHtml = sortedCalls + .map(function (tc, index) { + let truncatedTitle; + if (tc.summary) { + truncatedTitle = + tc.summary.length > 120 + ? tc.summary.substring(0, 120) + "..." + : tc.summary; + } else { + let firstSentence = tc.prompt.split(/[.!?]/)[0]; + truncatedTitle = + firstSentence.length > 120 + ? firstSentence.substring(0, 120) + "..." + : firstSentence; + } + let queueBadge = tc.isFromQueue + ? 'Queue' + : ""; + let isLatest = index === sortedCalls.length - 1; + let cardHtml = + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + escapeHtml(truncatedTitle) + + queueBadge + + "" + + "
    " + + "
    " + + '
    ' + + '
    ' + + formatMarkdown(tc.prompt) + + "
    " + + '
    ' + + '
    ' + + escapeHtml(tc.response) + + "
    " + + (tc.attachments ? renderAttachmentsHtml(tc.attachments) : "") + + "
    " + + "
    "; + return cardHtml; + }) + .join(""); + + toolHistoryArea.innerHTML = cardsHtml; + toolHistoryArea + .querySelectorAll(".tool-call-header") + .forEach(function (header) { + header.addEventListener("click", function (e) { + let card = header.closest(".tool-call-card"); + if (card) card.classList.toggle("expanded"); + }); + }); + renderMermaidDiagrams(); +} + +// ——— User message bubble for remote chat ——— +/** Add user message bubble to the chat stream area. */ +function addChatStreamUserBubble(text) { + if (!chatStreamArea) return; + chatStreamArea.classList.remove("hidden"); + var div = document.createElement("div"); + div.className = "chat-stream-msg user"; + div.textContent = text; + chatStreamArea.appendChild(div); + scrollToBottom(); +} + +function renderHistoryModal() { + if (!historyModalList) return; + if (persistedHistory.length === 0) { + historyModalList.innerHTML = + '
    No history yet
    '; + if (historyModalClearAll) historyModalClearAll.classList.add("hidden"); + return; + } + + if (historyModalClearAll) historyModalClearAll.classList.remove("hidden"); + function renderToolCallCard(tc) { + let truncatedTitle; + if (tc.summary) { + truncatedTitle = + tc.summary.length > 80 + ? tc.summary.substring(0, 80) + "..." + : tc.summary; + } else { + let firstSentence = tc.prompt.split(/[.!?]/)[0]; + truncatedTitle = + firstSentence.length > 80 + ? firstSentence.substring(0, 80) + "..." + : firstSentence; + } + let queueBadge = tc.isFromQueue + ? 'Queue' + : ""; + + return ( + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + escapeHtml(truncatedTitle) + + queueBadge + + "" + + "
    " + + '' + + "
    " + + '
    ' + + '
    ' + + formatMarkdown(tc.prompt) + + "
    " + + '
    ' + + '
    ' + + escapeHtml(tc.response) + + "
    " + + (tc.attachments ? renderAttachmentsHtml(tc.attachments) : "") + + "
    " + + "
    " + ); + } + + let cardsHtml = '
    '; + cardsHtml += persistedHistory.map(renderToolCallCard).join(""); + cardsHtml += "
    "; + + historyModalList.innerHTML = cardsHtml; + historyModalList + .querySelectorAll(".tool-call-header") + .forEach(function (header) { + header.addEventListener("click", function (e) { + if (e.target.closest(".tool-call-remove")) return; + let card = header.closest(".tool-call-card"); + if (card) card.classList.toggle("expanded"); + }); + }); + historyModalList + .querySelectorAll(".tool-call-remove") + .forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + let id = btn.getAttribute("data-id"); + if (id) { + vscode.postMessage({ type: "removeHistoryItem", callId: id }); + persistedHistory = persistedHistory.filter(function (tc) { + return tc.id !== id; + }); + renderHistoryModal(); + } + }); + }); +} + +function formatMarkdown(text) { + if (!text) return ""; + + // ReDoS prevention: truncate very long inputs before regex (OWASP mitigation) + if (text.length > MARKDOWN_MAX_LENGTH) { + text = + text.substring(0, MARKDOWN_MAX_LENGTH) + + "\n... (content truncated for display)"; + } + + // Normalize line endings (Windows \r\n to \n) + let processedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Store code blocks BEFORE escaping HTML to preserve backticks + let codeBlocks = []; + let mermaidBlocks = []; + let inlineCodeSpans = []; + + // Extract mermaid blocks first (before HTML escaping) + // Match ```mermaid followed by newline or just content + processedText = processedText.replace( + /```mermaid\s*\n([\s\S]*?)```/g, + function (match, code) { + let index = mermaidBlocks.length; + mermaidBlocks.push(code.trim()); + return "%%MERMAID" + index + "%%"; + }, + ); + + // Extract other code blocks (before HTML escaping) + // Match ```lang or just ``` followed by optional newline + processedText = processedText.replace( + /```(\w*)\s*\n?([\s\S]*?)```/g, + function (match, lang, code) { + let index = codeBlocks.length; + codeBlocks.push({ lang: lang || "", code: code.trim() }); + return "%%CODEBLOCK" + index + "%%"; + }, + ); + + // Extract inline code before escaping (prevents * and _ in `code` from being parsed) + processedText = processedText.replace(/`([^`\n]+)`/g, function (match, code) { + let index = inlineCodeSpans.length; + inlineCodeSpans.push(code); + return "%%INLINECODE" + index + "%%"; + }); + + // Now escape HTML on the remaining text + let html = escapeHtml(processedText); + + // Headers (## Header) - must be at start of line + html = html.replace(/^######\s+(.+)$/gm, "
    $1
    "); + html = html.replace(/^#####\s+(.+)$/gm, "
    $1
    "); + html = html.replace(/^####\s+(.+)$/gm, "

    $1

    "); + html = html.replace(/^###\s+(.+)$/gm, "

    $1

    "); + html = html.replace(/^##\s+(.+)$/gm, "

    $1

    "); + html = html.replace(/^#\s+(.+)$/gm, "

    $1

    "); + + // Horizontal rules (--- or ***) + html = html.replace(/^---+$/gm, "
    "); + html = html.replace(/^\*\*\*+$/gm, "
    "); + + // Blockquotes (> text) - simple single-line support + html = html.replace(/^>\s*(.*)$/gm, "
    $1
    "); + // Merge consecutive blockquotes + html = html.replace(/<\/blockquote>\n
    /g, "\n"); + + // Lists (ordered/unordered, including nested indentation) + // Security contract: html is already escaped above; list conversion must keep item text as-is. + html = convertMarkdownLists(html); + + // Markdown tables - SAFE approach to prevent ReDoS + // Instead of using nested quantifiers with regex (which can cause exponential backtracking), + // we use a line-by-line processing approach for safety + let tableLines = html.split("\n"); + let processedLines = []; + let tableBuffer = []; + let inTable = false; + + for (var lineIdx = 0; lineIdx < tableLines.length; lineIdx++) { + let line = tableLines[lineIdx]; + // Check if line looks like a table row (starts and ends with |) + let isTableRow = /^\|.+\|$/.test(line.trim()); + + if (isTableRow) { + tableBuffer.push(line); + inTable = true; + } else { + if (inTable && tableBuffer.length >= 2) { + // Process accumulated table buffer + const tableHtml = processTableBuffer(tableBuffer, MAX_TABLE_ROWS); + processedLines.push(tableHtml); + } + tableBuffer = []; + inTable = false; + processedLines.push(line); + } + } + // Handle table at end of content + if (inTable && tableBuffer.length >= 2) { + processedLines.push(processTableBuffer(tableBuffer, MAX_TABLE_ROWS)); + } + html = processedLines.join("\n"); + + // Tokenize markdown links before emphasis parsing so link targets are not mutated by markdown transforms. + let markdownLinksApi = window.TaskSyncMarkdownLinks; + let tokenizedLinks = null; + if ( + markdownLinksApi && + typeof markdownLinksApi.tokenizeMarkdownLinks === "function" + ) { + tokenizedLinks = markdownLinksApi.tokenizeMarkdownLinks(html); + html = tokenizedLinks.text; + } + + // Bold (**text** or __text__) + html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); + html = html.replace(/__([^_]+)__/g, "$1"); + + // Strikethrough (~~text~~) + html = html.replace(/~~([^~]+)~~/g, "$1"); + + // Italic (*text* or _text_) + // For *text*: require non-word boundaries around delimiters and alnum at content edges. + // This avoids false-positive matches in plain prose (e.g. regex snippets, list-marker-like asterisks). + html = html.replace( + /(^|[^\p{L}\p{N}_*])\*([\p{L}\p{N}](?:[^*\n]*?[\p{L}\p{N}])?)\*(?=[^\p{L}\p{N}_*]|$)/gu, + "$1$2", + ); + // For _text_: require non-word boundaries (Unicode-aware) around underscore markers + // This keeps punctuation-adjacent emphasis working while avoiding snake_case matches + html = html.replace( + /(^|[^\p{L}\p{N}_])_([^_\s](?:[^_]*[^_\s])?)_(?=[^\p{L}\p{N}_]|$)/gu, + "$1$2", + ); + + // Restore tokenized markdown links after emphasis parsing. + if ( + tokenizedLinks && + markdownLinksApi && + typeof markdownLinksApi.restoreTokenizedLinks === "function" + ) { + html = markdownLinksApi.restoreTokenizedLinks(html, tokenizedLinks.links); + } else if ( + markdownLinksApi && + typeof markdownLinksApi.convertMarkdownLinks === "function" + ) { + html = markdownLinksApi.convertMarkdownLinks(html); + } + + // Restore inline code after emphasis parsing so markdown markers inside code stay literal. + inlineCodeSpans.forEach(function (code, index) { + let escapedCode = escapeHtml(code); + let replacement = '' + escapedCode + ""; + html = html.replace("%%INLINECODE" + index + "%%", replacement); + }); + + // Line breaks - but collapse multiple consecutive breaks + // Don't add
    after block elements + html = html.replace(/\n{3,}/g, "\n\n"); + html = html.replace( + /(<\/h[1-6]>|<\/ul>|<\/ol>|<\/blockquote>|
    )\n/g, + "$1", + ); + html = html.replace(/\n/g, "
    "); + + // Restore code blocks + codeBlocks.forEach(function (block, index) { + let langAttr = block.lang ? ' data-lang="' + block.lang + '"' : ""; + let escapedCode = escapeHtml(block.code); + let replacement = + '
    " +
    +			escapedCode +
    +			"
    "; + html = html.replace("%%CODEBLOCK" + index + "%%", replacement); + }); + + // Restore mermaid blocks as diagrams + mermaidBlocks.forEach(function (code, index) { + let mermaidId = + "mermaid-" + + Date.now() + + "-" + + index + + "-" + + Math.random().toString(36).substr(2, 9); + let replacement = + '
    ' + + escapeHtml(code) + + "
    "; + html = html.replace("%%MERMAID" + index + "%%", replacement); + }); + + // Clean up excessive
    around block elements + html = html.replace( + /(
    )+(|<\/div>|<\/h[1-6]>|<\/ul>|<\/ol>|<\/blockquote>|
    )(
    )+/g, + "$1", + ); + + return html; +} + +// Mermaid rendering - lazy load and render +let mermaidLoaded = false; +let mermaidLoading = false; + +function loadMermaid(callback) { + if (mermaidLoaded) { + callback(); + return; + } + if (mermaidLoading) { + // Wait for existing load (with 10s timeout) + let checkCount = 0; + let checkInterval = setInterval(function () { + checkCount++; + if (mermaidLoaded) { + clearInterval(checkInterval); + callback(); + } else if (checkCount > 200) { + // 10s = 200 * 50ms + clearInterval(checkInterval); + console.error("Mermaid load timeout"); + } + }, 50); + return; + } + mermaidLoading = true; + + let script = document.createElement("script"); + script.src = + "https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js"; + script.crossOrigin = "anonymous"; + script.integrity = + "sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT"; + script.onload = function () { + window.mermaid.initialize({ + startOnLoad: false, + theme: document.body.classList.contains("vscode-light") + ? "default" + : "dark", + securityLevel: "strict", + fontFamily: "var(--vscode-font-family)", + }); + mermaidLoaded = true; + mermaidLoading = false; + callback(); + }; + script.onerror = function () { + mermaidLoading = false; + console.error("Failed to load mermaid.js"); + }; + document.head.appendChild(script); +} + +function renderMermaidDiagrams() { + let containers = document.querySelectorAll( + ".mermaid-container:not(.rendered)", + ); + if (containers.length === 0) return; + + loadMermaid(function () { + containers.forEach(function (container) { + let mermaidDiv = container.querySelector(".mermaid"); + if (!mermaidDiv) return; + + let code = mermaidDiv.textContent; + let id = mermaidDiv.id; + + try { + window.mermaid + .render(id + "-svg", code) + .then(function (result) { + mermaidDiv.innerHTML = result.svg; + container.classList.add("rendered"); + }) + .catch(function (err) { + // Show code block as fallback on error + mermaidDiv.innerHTML = + '
    ' +
    +							escapeHtml(code) +
    +							"
    "; + container.classList.add("rendered", "error"); + }); + } catch (err) { + mermaidDiv.innerHTML = + '
    ' +
    +					escapeHtml(code) +
    +					"
    "; + container.classList.add("rendered", "error"); + } + }); + }); +} + +/** + * Update welcome section visibility based on current session state + * Hide welcome when there are completed tool calls or a pending call + */ +function updateWelcomeSectionVisibility() { + if (!welcomeSection) return; + let hasCompletedCalls = currentSessionCalls.some(function (tc) { + return tc.status === "completed"; + }); + let hasPendingMessage = + pendingMessage && !pendingMessage.classList.contains("hidden"); + let shouldHide = + hasCompletedCalls || pendingToolCall !== null || hasPendingMessage; + welcomeSection.classList.toggle("hidden", shouldHide); +} + +/** + * Auto-scroll chat container to bottom + */ +function scrollToBottom() { + if (!chatContainer) return; + requestAnimationFrame(function () { + chatContainer.scrollTop = chatContainer.scrollHeight; + }); +} diff --git a/tasksync-chat/src/webview-ui/settings.js b/tasksync-chat/src/webview-ui/settings.js new file mode 100644 index 0000000..0db9f25 --- /dev/null +++ b/tasksync-chat/src/webview-ui/settings.js @@ -0,0 +1,551 @@ +// ===== SETTINGS MODAL FUNCTIONS ===== + +function openSettingsModal() { + if (!settingsModalOverlay) return; + vscode.postMessage({ type: "openSettingsModal" }); + settingsModalOverlay.classList.remove("hidden"); +} + +function closeSettingsModal() { + if (!settingsModalOverlay) return; + settingsModalOverlay.classList.add("hidden"); + hideAddPromptForm(); +} + +function toggleSoundSetting() { + soundEnabled = !soundEnabled; + updateSoundToggleUI(); + vscode.postMessage({ type: "updateSoundSetting", enabled: soundEnabled }); +} + +function updateSoundToggleUI() { + if (!soundToggle) return; + soundToggle.classList.toggle("active", soundEnabled); + soundToggle.setAttribute("aria-checked", soundEnabled ? "true" : "false"); +} + +function toggleInteractiveApprovalSetting() { + interactiveApprovalEnabled = !interactiveApprovalEnabled; + updateInteractiveApprovalToggleUI(); + vscode.postMessage({ + type: "updateInteractiveApprovalSetting", + enabled: interactiveApprovalEnabled, + }); +} + +function updateInteractiveApprovalToggleUI() { + if (!interactiveApprovalToggle) return; + interactiveApprovalToggle.classList.toggle( + "active", + interactiveApprovalEnabled, + ); + interactiveApprovalToggle.setAttribute( + "aria-checked", + interactiveApprovalEnabled ? "true" : "false", + ); +} + +function toggleSendWithCtrlEnterSetting() { + sendWithCtrlEnter = !sendWithCtrlEnter; + updateSendWithCtrlEnterToggleUI(); + vscode.postMessage({ + type: "updateSendWithCtrlEnterSetting", + enabled: sendWithCtrlEnter, + }); +} + +function updateSendWithCtrlEnterToggleUI() { + if (!sendShortcutToggle) return; + sendShortcutToggle.classList.toggle("active", sendWithCtrlEnter); + sendShortcutToggle.setAttribute( + "aria-checked", + sendWithCtrlEnter ? "true" : "false", + ); +} + +function toggleAutopilotSetting() { + autopilotEnabled = !autopilotEnabled; + updateAutopilotToggleUI(); + vscode.postMessage({ + type: "updateAutopilotSetting", + enabled: autopilotEnabled, + }); +} + +function updateAutopilotToggleUI() { + if (autopilotToggle) { + autopilotToggle.classList.toggle("active", autopilotEnabled); + autopilotToggle.setAttribute( + "aria-checked", + autopilotEnabled ? "true" : "false", + ); + } +} + +function handleResponseTimeoutChange() { + if (!responseTimeoutSelect) return; + let value = parseInt(responseTimeoutSelect.value, 10); + if (!isNaN(value)) { + responseTimeout = value; + vscode.postMessage({ type: "updateResponseTimeout", value: value }); + } +} + +function updateResponseTimeoutUI() { + if (!responseTimeoutSelect) return; + responseTimeoutSelect.value = String(responseTimeout); +} + +function handleSessionWarningHoursChange() { + if (!sessionWarningHoursSelect) return; + + let value = parseInt(sessionWarningHoursSelect.value, 10); + if (!isNaN(value) && value >= 0 && value <= SESSION_WARNING_HOURS_MAX) { + sessionWarningHours = value; + vscode.postMessage({ type: "updateSessionWarningHours", value: value }); + } + + sessionWarningHoursSelect.value = String(sessionWarningHours); +} + +function updateSessionWarningHoursUI() { + if (!sessionWarningHoursSelect) return; + sessionWarningHoursSelect.value = String(sessionWarningHours); +} + +function handleMaxAutoResponsesChange() { + if (!maxAutoResponsesInput) return; + let value = parseInt(maxAutoResponsesInput.value, 10); + if (!isNaN(value) && value >= 1 && value <= MAX_AUTO_RESPONSES_LIMIT) { + maxConsecutiveAutoResponses = value; + vscode.postMessage({ + type: "updateMaxConsecutiveAutoResponses", + value: value, + }); + } else { + // Reset to valid value + maxAutoResponsesInput.value = maxConsecutiveAutoResponses; + } +} + +function updateMaxAutoResponsesUI() { + if (!maxAutoResponsesInput) return; + maxAutoResponsesInput.value = maxConsecutiveAutoResponses; +} + +/** + * Toggle human-like delay. When enabled, a random delay (jitter) + * between min and max seconds is applied before each auto-response, + * simulating natural human reading and typing time. + */ +function toggleHumanDelaySetting() { + humanLikeDelayEnabled = !humanLikeDelayEnabled; + vscode.postMessage({ + type: "updateHumanDelaySetting", + enabled: humanLikeDelayEnabled, + }); + updateHumanDelayUI(); +} + +/** + * Update minimum delay (seconds). Clamps to valid range [1, max]. + * Sends new value to extension for persistence in VS Code settings. + */ +function handleHumanDelayMinChange() { + if (!humanDelayMinInput) return; + let value = parseInt(humanDelayMinInput.value, 10); + if ( + !isNaN(value) && + value >= HUMAN_DELAY_MIN_LOWER && + value <= HUMAN_DELAY_MIN_UPPER + ) { + // Ensure min <= max + if (value > humanLikeDelayMax) { + value = humanLikeDelayMax; + } + humanLikeDelayMin = value; + vscode.postMessage({ type: "updateHumanDelayMin", value: value }); + } + humanDelayMinInput.value = humanLikeDelayMin; +} + +/** + * Update maximum delay (seconds). Clamps to valid range [min, 60]. + * Sends new value to extension for persistence in VS Code settings. + */ +function handleHumanDelayMaxChange() { + if (!humanDelayMaxInput) return; + let value = parseInt(humanDelayMaxInput.value, 10); + if ( + !isNaN(value) && + value >= HUMAN_DELAY_MAX_LOWER && + value <= HUMAN_DELAY_MAX_UPPER + ) { + // Ensure max >= min + if (value < humanLikeDelayMin) { + value = humanLikeDelayMin; + } + humanLikeDelayMax = value; + vscode.postMessage({ type: "updateHumanDelayMax", value: value }); + } + humanDelayMaxInput.value = humanLikeDelayMax; +} + +function updateHumanDelayUI() { + if (humanDelayToggle) { + humanDelayToggle.classList.toggle("active", humanLikeDelayEnabled); + humanDelayToggle.setAttribute( + "aria-checked", + humanLikeDelayEnabled ? "true" : "false", + ); + } + if (humanDelayRangeContainer) { + humanDelayRangeContainer.style.display = humanLikeDelayEnabled + ? "flex" + : "none"; + } + if (humanDelayMinInput) { + humanDelayMinInput.value = humanLikeDelayMin; + } + if (humanDelayMaxInput) { + humanDelayMaxInput.value = humanLikeDelayMax; + } +} + +function showAddPromptForm() { + if (!addPromptForm || !addPromptBtn) return; + addPromptForm.classList.remove("hidden"); + addPromptBtn.classList.add("hidden"); + let nameInput = document.getElementById("prompt-name-input"); + let textInput = document.getElementById("prompt-text-input"); + if (nameInput) { + nameInput.value = ""; + nameInput.focus(); + } + if (textInput) textInput.value = ""; + // Clear edit mode + addPromptForm.removeAttribute("data-editing-id"); +} + +function hideAddPromptForm() { + if (!addPromptForm || !addPromptBtn) return; + addPromptForm.classList.add("hidden"); + addPromptBtn.classList.remove("hidden"); + addPromptForm.removeAttribute("data-editing-id"); +} + +function saveNewPrompt() { + let nameInput = document.getElementById("prompt-name-input"); + let textInput = document.getElementById("prompt-text-input"); + if (!nameInput || !textInput) return; + + let name = nameInput.value.trim(); + let prompt = textInput.value.trim(); + + if (!name || !prompt) { + return; + } + + let editingId = addPromptForm.getAttribute("data-editing-id"); + if (editingId) { + // Editing existing prompt + vscode.postMessage({ + type: "editReusablePrompt", + id: editingId, + name: name, + prompt: prompt, + }); + } else { + // Adding new prompt + vscode.postMessage({ + type: "addReusablePrompt", + name: name, + prompt: prompt, + }); + } + + hideAddPromptForm(); +} + +// ========== Autopilot Prompts Array Functions ========== + +// Track which autopilot prompt is being edited (-1 = adding new, >= 0 = editing index) +let editingAutopilotPromptIndex = -1; +// Track drag state +let draggedAutopilotIndex = -1; + +function renderAutopilotPromptsList() { + if (!autopilotPromptsList) return; + + if (autopilotPrompts.length === 0) { + autopilotPromptsList.innerHTML = + '
    No prompts added. Add prompts to cycle through during Autopilot.
    '; + return; + } + + // Render list with drag handles, numbers, edit/delete buttons + autopilotPromptsList.innerHTML = autopilotPrompts + .map(function (prompt, index) { + let truncated = + prompt.length > 80 ? prompt.substring(0, 80) + "..." : prompt; + let tooltipText = + prompt.length > 300 ? prompt.substring(0, 300) + "..." : prompt; + tooltipText = escapeHtml(tooltipText); + return ( + '
    ' + + '' + + '' + + (index + 1) + + "." + + '' + + escapeHtml(truncated) + + "" + + '
    ' + + '' + + '' + + "
    " + ); + }) + .join(""); +} + +function showAddAutopilotPromptForm() { + if (!addAutopilotPromptForm || !autopilotPromptInput) return; + editingAutopilotPromptIndex = -1; + autopilotPromptInput.value = ""; + addAutopilotPromptForm.classList.remove("hidden"); + addAutopilotPromptForm.removeAttribute("data-editing-index"); + autopilotPromptInput.focus(); +} + +function hideAddAutopilotPromptForm() { + if (!addAutopilotPromptForm || !autopilotPromptInput) return; + addAutopilotPromptForm.classList.add("hidden"); + autopilotPromptInput.value = ""; + editingAutopilotPromptIndex = -1; + addAutopilotPromptForm.removeAttribute("data-editing-index"); +} + +function saveAutopilotPrompt() { + if (!autopilotPromptInput) return; + let prompt = autopilotPromptInput.value.trim(); + if (!prompt) return; + + let editingIndex = addAutopilotPromptForm.getAttribute("data-editing-index"); + if (editingIndex !== null) { + // Editing existing + vscode.postMessage({ + type: "editAutopilotPrompt", + index: parseInt(editingIndex, 10), + prompt: prompt, + }); + } else { + // Adding new + vscode.postMessage({ type: "addAutopilotPrompt", prompt: prompt }); + } + hideAddAutopilotPromptForm(); +} + +function handleAutopilotPromptsListClick(e) { + let target = e.target.closest(".prompt-item-btn"); + if (!target) return; + + let index = parseInt(target.getAttribute("data-index"), 10); + if (isNaN(index)) return; + + if (target.classList.contains("edit")) { + editAutopilotPrompt(index); + } else if (target.classList.contains("delete")) { + deleteAutopilotPrompt(index); + } +} + +function editAutopilotPrompt(index) { + if (index < 0 || index >= autopilotPrompts.length) return; + if (!addAutopilotPromptForm || !autopilotPromptInput) return; + + let prompt = autopilotPrompts[index]; + editingAutopilotPromptIndex = index; + autopilotPromptInput.value = prompt; + addAutopilotPromptForm.setAttribute("data-editing-index", index); + addAutopilotPromptForm.classList.remove("hidden"); + autopilotPromptInput.focus(); +} + +function deleteAutopilotPrompt(index) { + if (index < 0 || index >= autopilotPrompts.length) return; + vscode.postMessage({ type: "removeAutopilotPrompt", index: index }); +} + +function handleAutopilotDragStart(e) { + let item = e.target.closest(".autopilot-prompt-item"); + if (!item) return; + draggedAutopilotIndex = parseInt(item.getAttribute("data-index"), 10); + item.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", draggedAutopilotIndex); +} + +function handleAutopilotDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + let item = e.target.closest(".autopilot-prompt-item"); + if (!item || !autopilotPromptsList) return; + + // Remove all drag-over classes first + autopilotPromptsList + .querySelectorAll(".autopilot-prompt-item") + .forEach(function (el) { + el.classList.remove("drag-over-top", "drag-over-bottom"); + }); + + // Determine if we're above or below center of target + let rect = item.getBoundingClientRect(); + let midY = rect.top + rect.height / 2; + if (e.clientY < midY) { + item.classList.add("drag-over-top"); + } else { + item.classList.add("drag-over-bottom"); + } +} + +function handleAutopilotDragEnd(e) { + draggedAutopilotIndex = -1; + if (!autopilotPromptsList) return; + autopilotPromptsList + .querySelectorAll(".autopilot-prompt-item") + .forEach(function (el) { + el.classList.remove("dragging", "drag-over-top", "drag-over-bottom"); + }); +} + +function handleAutopilotDrop(e) { + e.preventDefault(); + let item = e.target.closest(".autopilot-prompt-item"); + if (!item || draggedAutopilotIndex < 0) return; + + let toIndex = parseInt(item.getAttribute("data-index"), 10); + if (isNaN(toIndex) || draggedAutopilotIndex === toIndex) { + handleAutopilotDragEnd(e); + return; + } + + // Determine insert position based on where we dropped + let rect = item.getBoundingClientRect(); + let midY = rect.top + rect.height / 2; + let insertBelow = e.clientY >= midY; + + // Calculate actual target index + let targetIndex = toIndex; + if (insertBelow && toIndex < autopilotPrompts.length - 1) { + targetIndex = toIndex + 1; + } + + // Adjust for removal of source + if (draggedAutopilotIndex < targetIndex) { + targetIndex--; + } + + targetIndex = Math.max(0, Math.min(targetIndex, autopilotPrompts.length - 1)); + + if (draggedAutopilotIndex !== targetIndex) { + vscode.postMessage({ + type: "reorderAutopilotPrompts", + fromIndex: draggedAutopilotIndex, + toIndex: targetIndex, + }); + } + + handleAutopilotDragEnd(e); +} + +// ========== End Autopilot Prompts Functions ========== + +function renderPromptsList() { + if (!promptsList) return; + + if (reusablePrompts.length === 0) { + promptsList.innerHTML = ""; + return; + } + + // Compact list - show only name, full prompt on hover via title + promptsList.innerHTML = reusablePrompts + .map(function (p) { + // Truncate very long prompts for tooltip to prevent massive tooltips + let tooltipText = + p.prompt.length > 300 ? p.prompt.substring(0, 300) + "..." : p.prompt; + // Escape for HTML attribute + tooltipText = escapeHtml(tooltipText); + return ( + '
    ' + + '
    ' + + '/' + + escapeHtml(p.name) + + "" + + "
    " + + '
    ' + + '' + + '' + + "
    " + ); + }) + .join(""); + + // Bind edit/delete events + promptsList.querySelectorAll(".prompt-item-btn.edit").forEach(function (btn) { + btn.addEventListener("click", function () { + let id = btn.getAttribute("data-id"); + editPrompt(id); + }); + }); + + promptsList + .querySelectorAll(".prompt-item-btn.delete") + .forEach(function (btn) { + btn.addEventListener("click", function () { + let id = btn.getAttribute("data-id"); + deletePrompt(id); + }); + }); +} + +function editPrompt(id) { + let prompt = reusablePrompts.find(function (p) { + return p.id === id; + }); + if (!prompt) return; + + let nameInput = document.getElementById("prompt-name-input"); + let textInput = document.getElementById("prompt-text-input"); + if (!nameInput || !textInput) return; + + // Show form with existing values + addPromptForm.classList.remove("hidden"); + addPromptBtn.classList.add("hidden"); + addPromptForm.setAttribute("data-editing-id", id); + + nameInput.value = prompt.name; + textInput.value = prompt.prompt; + nameInput.focus(); +} + +function deletePrompt(id) { + vscode.postMessage({ type: "removeReusablePrompt", id: id }); +} diff --git a/tasksync-chat/src/webview-ui/slashCommands.js b/tasksync-chat/src/webview-ui/slashCommands.js new file mode 100644 index 0000000..37f9e2e --- /dev/null +++ b/tasksync-chat/src/webview-ui/slashCommands.js @@ -0,0 +1,205 @@ +// ===== SLASH COMMAND FUNCTIONS ===== + +/** + * Expand /commandName patterns to their full prompt text + * Only expands known commands at the start of lines or after whitespace + */ +function expandSlashCommands(text) { + if (!text || reusablePrompts.length === 0) return text; + + // Use stored mappings from selectSlashItem if available + let mappings = + chatInput && chatInput._slashPrompts ? chatInput._slashPrompts : {}; + + // Build a regex to match all known prompt names + let promptNames = reusablePrompts.map(function (p) { + return p.name; + }); + if (Object.keys(mappings).length > 0) { + Object.keys(mappings).forEach(function (name) { + if (promptNames.indexOf(name) === -1) promptNames.push(name); + }); + } + + // Match /promptName at start or after whitespace + let expanded = text; + promptNames.forEach(function (name) { + // Escape special regex chars in name + let escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + let regex = new RegExp("(^|\\s)/" + escapedName + "(?=\\s|$)", "g"); + let fullPrompt = + mappings[name] || + ( + reusablePrompts.find(function (p) { + return p.name === name; + }) || {} + ).prompt || + ""; + if (fullPrompt) { + expanded = expanded.replace(regex, "$1" + fullPrompt); + } + }); + + // Clear stored mappings after expansion + if (chatInput) chatInput._slashPrompts = {}; + + return expanded.trim(); +} + +function handleSlashCommands() { + if (!chatInput) return; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + + // Find slash at start of input or after whitespace + let slashPos = -1; + for (var i = cursorPos - 1; i >= 0; i--) { + if (value[i] === "/") { + // Check if it's at start or after whitespace + if (i === 0 || /\s/.test(value[i - 1])) { + slashPos = i; + } + break; + } + if (/\s/.test(value[i])) break; + } + + if (slashPos >= 0 && reusablePrompts.length > 0) { + let query = value.substring(slashPos + 1, cursorPos); + slashStartPos = slashPos; + if (slashDebounceTimer) clearTimeout(slashDebounceTimer); + slashDebounceTimer = setTimeout(function () { + // Filter locally for instant results + let queryLower = query.toLowerCase(); + let matchingPrompts = reusablePrompts.filter(function (p) { + return ( + p.name.toLowerCase().includes(queryLower) || + p.prompt.toLowerCase().includes(queryLower) + ); + }); + showSlashDropdown(matchingPrompts); + }, 50); + } else if (slashDropdownVisible) { + hideSlashDropdown(); + } +} + +function showSlashDropdown(results) { + if (!slashDropdown || !slashList || !slashEmpty) return; + slashResults = results; + selectedSlashIndex = results.length > 0 ? 0 : -1; + + // Hide file autocomplete if showing slash commands + hideAutocomplete(); + + if (results.length === 0) { + slashList.classList.add("hidden"); + slashEmpty.classList.remove("hidden"); + } else { + slashList.classList.remove("hidden"); + slashEmpty.classList.add("hidden"); + renderSlashList(); + } + slashDropdown.classList.remove("hidden"); + slashDropdownVisible = true; +} + +function hideSlashDropdown() { + if (slashDropdown) slashDropdown.classList.add("hidden"); + slashDropdownVisible = false; + slashResults = []; + selectedSlashIndex = -1; + slashStartPos = -1; + if (slashDebounceTimer) { + clearTimeout(slashDebounceTimer); + slashDebounceTimer = null; + } +} + +function renderSlashList() { + if (!slashList) return; + slashList.innerHTML = slashResults + .map(function (p, index) { + let truncatedPrompt = + p.prompt.length > 50 ? p.prompt.substring(0, 50) + "..." : p.prompt; + // Prepare tooltip text - escape for HTML attribute + let tooltipText = + p.prompt.length > 500 ? p.prompt.substring(0, 500) + "..." : p.prompt; + tooltipText = escapeHtml(tooltipText); + return ( + '
    ' + + '' + + '
    ' + + '/' + + escapeHtml(p.name) + + "" + + '' + + escapeHtml(truncatedPrompt) + + "" + + "
    " + ); + }) + .join(""); + + slashList.querySelectorAll(".slash-item").forEach(function (item) { + item.addEventListener("click", function () { + selectSlashItem(parseInt(item.getAttribute("data-index"), 10)); + }); + item.addEventListener("mouseenter", function () { + selectedSlashIndex = parseInt(item.getAttribute("data-index"), 10); + updateSlashSelection(); + }); + }); + scrollToSelectedSlashItem(); +} + +function updateSlashSelection() { + if (!slashList) return; + slashList.querySelectorAll(".slash-item").forEach(function (item, index) { + item.classList.toggle("selected", index === selectedSlashIndex); + }); + scrollToSelectedSlashItem(); +} + +function scrollToSelectedSlashItem() { + let selectedItem = slashList + ? slashList.querySelector(".slash-item.selected") + : null; + if (selectedItem) + selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" }); +} + +function selectSlashItem(index) { + if ( + index < 0 || + index >= slashResults.length || + !chatInput || + slashStartPos < 0 + ) + return; + let prompt = slashResults[index]; + let value = chatInput.value; + let cursorPos = chatInput.selectionStart; + + // Create a slash tag representation - when sent, we'll expand it to full prompt + // For now, insert /name as text and store the mapping + let slashText = "/" + prompt.name + " "; + chatInput.value = + value.substring(0, slashStartPos) + slashText + value.substring(cursorPos); + let newCursorPos = slashStartPos + slashText.length; + chatInput.setSelectionRange(newCursorPos, newCursorPos); + + // Store the prompt reference for expansion on send + if (!chatInput._slashPrompts) chatInput._slashPrompts = {}; + chatInput._slashPrompts[prompt.name] = prompt.prompt; + + hideSlashDropdown(); + chatInput.focus(); + updateSendButtonState(); +} diff --git a/tasksync-chat/src/webview-ui/state.js b/tasksync-chat/src/webview-ui/state.js new file mode 100644 index 0000000..bae8458 --- /dev/null +++ b/tasksync-chat/src/webview-ui/state.js @@ -0,0 +1,166 @@ +// Restore persisted state (survives sidebar switch) +const previousState = vscode.getState() || {}; + +// Settings defaults & validation ranges — use shared constants if available (remote mode) +// Keep timeout options aligned with select values to avoid invalid UI state. +const RESPONSE_TIMEOUT_ALLOWED_VALUES = + typeof TASKSYNC_RESPONSE_TIMEOUT_ALLOWED !== "undefined" + ? new Set(TASKSYNC_RESPONSE_TIMEOUT_ALLOWED) + : new Set([ + 0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 150, 180, 210, + 240, + ]); +const RESPONSE_TIMEOUT_DEFAULT = + typeof TASKSYNC_RESPONSE_TIMEOUT_DEFAULT !== "undefined" + ? TASKSYNC_RESPONSE_TIMEOUT_DEFAULT + : 60; +const MAX_DISPLAY_HISTORY = 20; // Client-side display limit (matches MAX_REMOTE_HISTORY_ITEMS) + +const DEFAULT_SESSION_WARNING_HOURS = + typeof TASKSYNC_DEFAULT_SESSION_WARNING_HOURS !== "undefined" + ? TASKSYNC_DEFAULT_SESSION_WARNING_HOURS + : 2; +const SESSION_WARNING_HOURS_MAX = + typeof TASKSYNC_SESSION_WARNING_HOURS_MAX !== "undefined" + ? TASKSYNC_SESSION_WARNING_HOURS_MAX + : 8; +const DEFAULT_MAX_AUTO_RESPONSES = + typeof TASKSYNC_DEFAULT_MAX_AUTO_RESPONSES !== "undefined" + ? TASKSYNC_DEFAULT_MAX_AUTO_RESPONSES + : 5; +const MAX_AUTO_RESPONSES_LIMIT = + typeof TASKSYNC_MAX_AUTO_RESPONSES_LIMIT !== "undefined" + ? TASKSYNC_MAX_AUTO_RESPONSES_LIMIT + : 100; +const DEFAULT_HUMAN_DELAY_MIN = + typeof TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MIN !== "undefined" + ? TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MIN + : 2; +const DEFAULT_HUMAN_DELAY_MAX = + typeof TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MAX !== "undefined" + ? TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MAX + : 6; +const HUMAN_DELAY_MIN_LOWER = + typeof TASKSYNC_HUMAN_DELAY_MIN_LOWER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MIN_LOWER + : 1; +const HUMAN_DELAY_MIN_UPPER = + typeof TASKSYNC_HUMAN_DELAY_MIN_UPPER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MIN_UPPER + : 30; +const HUMAN_DELAY_MAX_LOWER = + typeof TASKSYNC_HUMAN_DELAY_MAX_LOWER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MAX_LOWER + : 2; +const HUMAN_DELAY_MAX_UPPER = + typeof TASKSYNC_HUMAN_DELAY_MAX_UPPER !== "undefined" + ? TASKSYNC_HUMAN_DELAY_MAX_UPPER + : 60; + +// State +let promptQueue = []; +let queueVersion = 0; // Optimistic concurrency control for queue operations +let queueEnabled = true; // Default to true (Queue mode ON by default) +let dropdownOpen = false; +let currentAttachments = previousState.attachments || []; // Restore attachments +let selectedCard = "queue"; +let currentSessionCalls = []; // Current session tool calls (shown in chat) +let persistedHistory = []; // Past sessions history (shown in modal) +let lastContextMenuTarget = null; // Tracks where right-click was triggered for copy fallback behavior +let lastContextMenuTimestamp = 0; // Ensures stale right-click targets are not reused for copy +let pendingToolCall = null; +let isProcessingResponse = false; // True when AI is processing user's response +let isApprovalQuestion = false; // True when current pending question is an approval-type question +let currentChoices = []; // Parsed choices from multi-choice questions + +// Settings state (initialized from constants to maintain SSOT) +let soundEnabled = true; +let interactiveApprovalEnabled = true; +let sendWithCtrlEnter = false; +let autopilotEnabled = false; +let autopilotText = ""; +let autopilotPrompts = []; +let responseTimeout = RESPONSE_TIMEOUT_DEFAULT; +let sessionWarningHours = DEFAULT_SESSION_WARNING_HOURS; +let maxConsecutiveAutoResponses = DEFAULT_MAX_AUTO_RESPONSES; + +// Human-like delay: random jitter simulates natural reading/typing time +let humanLikeDelayEnabled = true; +let humanLikeDelayMin = DEFAULT_HUMAN_DELAY_MIN; +let humanLikeDelayMax = DEFAULT_HUMAN_DELAY_MAX; +const CONTEXT_MENU_COPY_MAX_AGE_MS = 30000; + +// Tracks local edits to prevent stale settings overwriting user input mid-typing. +let reusablePrompts = []; +let audioUnlocked = false; // Track if audio playback has been unlocked by user gesture + +// Slash command autocomplete state +let slashDropdownVisible = false; +let slashResults = []; +let selectedSlashIndex = -1; +let slashStartPos = -1; +let slashDebounceTimer = null; + +// Persisted input value (restored from state) +let persistedInputValue = previousState.inputValue || ""; + +// Edit mode state +let editingPromptId = null; +let editingOriginalPrompt = null; +let savedInputValue = ""; // Save input value when entering edit mode + +// Autocomplete state +let autocompleteVisible = false; +let autocompleteResults = []; +let selectedAutocompleteIndex = -1; +let autocompleteStartPos = -1; +let searchDebounceTimer = null; + +// DOM Elements +let chatInput, sendBtn, attachBtn, modeBtn, modeDropdown, modeLabel; +let inputHighlighter; // Overlay for syntax highlighting in input +let queueSection, queueHeader, queueList, queueCount; +let chatContainer, + chipsContainer, + autocompleteDropdown, + autocompleteList, + autocompleteEmpty; +let inputContainer, inputAreaContainer, welcomeSection; +let cardVibe, cardSpec, toolHistoryArea, pendingMessage; +let chatStreamArea; // DOM container for remote user message bubbles +let historyModal, + historyModalOverlay, + historyModalList, + historyModalClose, + historyModalClearAll; + +// Edit mode elements +let actionsLeft, + actionsBar, + editActionsContainer, + editCancelBtn, + editConfirmBtn; +// Approval modal elements +let approvalModal, approvalContinueBtn, approvalNoBtn; +// Slash command elements +let slashDropdown, slashList, slashEmpty; +// Settings modal elements +let settingsModal, settingsModalOverlay, settingsModalClose; +let soundToggle, + interactiveApprovalToggle, + sendShortcutToggle, + autopilotToggle, + promptsList, + addPromptBtn, + addPromptForm; +let autopilotPromptsList, + autopilotAddBtn, + addAutopilotPromptForm, + autopilotPromptInput, + saveAutopilotPromptBtn, + cancelAutopilotPromptBtn; +let responseTimeoutSelect, sessionWarningHoursSelect, maxAutoResponsesInput; +let humanDelayToggle, + humanDelayRangeContainer, + humanDelayMinInput, + humanDelayMaxInput; diff --git a/tasksync-chat/src/webview/choiceParser.test.ts b/tasksync-chat/src/webview/choiceParser.test.ts new file mode 100644 index 0000000..04131a2 --- /dev/null +++ b/tasksync-chat/src/webview/choiceParser.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from "vitest"; +import { + CHOICE_LABEL_MAX_LENGTH, + isApprovalQuestion, + parseChoices, + SHORT_QUESTION_THRESHOLD, +} from "../webview/choiceParser"; + +// ─── parseChoices ──────────────────────────────────────────── + +describe("parseChoices", () => { + describe("numbered lists (multi-line)", () => { + it("detects a basic numbered list (1. 2. 3.)", () => { + const text = "Which language?\n1. JavaScript\n2. Python\n3. Rust"; + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0]).toMatchObject({ value: "1", shortLabel: "1" }); + expect(choices[1]).toMatchObject({ value: "2", shortLabel: "2" }); + expect(choices[2]).toMatchObject({ value: "3", shortLabel: "3" }); + }); + + it("detects numbered list with parentheses (1) 2) 3))", () => { + const text = "Pick one:\n1) Option A\n2) Option B\n3) Option C"; + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0].label).toContain("Option A"); + }); + + it("detects bold markdown numbered options", () => { + const text = "Choose:\n**1. First choice**\n**2. Second choice**"; + const choices = parseChoices(text); + expect(choices).toHaveLength(2); + expect(choices[0].label).toContain("First choice"); + }); + + it("returns first list when multiple numbered lists exist", () => { + const text = + "Main choices:\n1. Alpha\n2. Beta\n3. Gamma\n\nExamples:\n1. Example one\n2. Example two"; + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0].label).toContain("Alpha"); + }); + + it("truncates long labels to CHOICE_LABEL_MAX_LENGTH", () => { + const longOption = "A".repeat(50); + const text = `Pick:\n1. ${longOption}\n2. Short`; + const choices = parseChoices(text); + expect(choices[0].label.length).toBeLessThanOrEqual( + CHOICE_LABEL_MAX_LENGTH, + ); + expect(choices[0].label).toContain("..."); + }); + + it("strips trailing punctuation from labels", () => { + const text = "Pick:\n1. Continue?\n2. Stop!"; + const choices = parseChoices(text); + expect(choices[0].label).toBe("Continue"); + expect(choices[1].label).toBe("Stop"); + }); + }); + + describe("inline numbered lists", () => { + it("detects inline numbered options", () => { + const text = "Choose: 1. JavaScript 2. Python 3. Rust"; + const choices = parseChoices(text); + expect(choices.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe("lettered lists (multi-line)", () => { + it("detects lettered list (A. B. C.)", () => { + const text = "Options:\nA. First\nB. Second\nC. Third"; + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0]).toMatchObject({ value: "A", shortLabel: "A" }); + expect(choices[1]).toMatchObject({ value: "B", shortLabel: "B" }); + expect(choices[2]).toMatchObject({ value: "C", shortLabel: "C" }); + }); + + it("detects lettered list with parentheses (A) B) C))", () => { + const text = "Pick:\nA) Alpha\nB) Bravo\nC) Charlie"; + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0].label).toContain("Alpha"); + }); + + it("normalises letters to uppercase", () => { + const text = "Pick:\na. lower alpha\nb. lower bravo"; + const choices = parseChoices(text); + expect(choices[0].value).toBe("A"); + expect(choices[1].value).toBe("B"); + }); + }); + + describe("Option X: pattern", () => { + it("detects Option A: / Option B: style", () => { + const text = + "Option A: Use React for the frontend\nOption B: Use Vue for the frontend"; + const choices = parseChoices(text); + expect(choices).toHaveLength(2); + expect(choices[0].value).toBe("Option A"); + expect(choices[1].value).toBe("Option B"); + }); + + it("detects Option 1: / Option 2: style", () => { + const text = + "Option 1: Keep the current implementation\nOption 2: Refactor to use hooks"; + const choices = parseChoices(text); + expect(choices).toHaveLength(2); + // Matched by inline numbered pattern (Pattern 1b), not Option X: pattern + expect(choices[0].value).toBe("1"); + expect(choices[1].value).toBe("2"); + }); + }); + + describe("inline lettered lists", () => { + it("detects inline lettered A. B. C. pattern (uppercase only)", () => { + const text = "A. agree B. disagree C. maybe later"; + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0].value).toBe("A"); + expect(choices[1].value).toBe("B"); + expect(choices[2].value).toBe("C"); + }); + }); + + describe("lettered list boundary detection", () => { + it("uses first group when two lettered lists are separated by > 3 lines", () => { + const text = [ + "Main choices:", + "A. Alpha", + "B. Bravo", + "C. Charlie", + "", + "Some extra info here.", + "More context about the above.", + "Even more details.", + "And yet another line.", + "A. Unrelated item", + "B. Another unrelated", + ].join("\n"); + const choices = parseChoices(text); + expect(choices).toHaveLength(3); + expect(choices[0].label).toContain("Alpha"); + expect(choices[2].label).toContain("Charlie"); + }); + }); + + describe("edge cases", () => { + it("returns empty array for plain text", () => { + expect(parseChoices("Hello world")).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(parseChoices("")).toEqual([]); + }); + + it("returns empty array for single-item list (needs >= 2)", () => { + expect(parseChoices("Only:\n1. One item")).toEqual([]); + }); + + it("ignores items with very short text (<3 chars)", () => { + const text = "Pick:\n1. OK\n2. Go\n3. Something valid"; + const choices = parseChoices(text); + // "OK" and "Go" are only 2 chars → skipped; only "Something valid" remains (1 item → empty) + // or if parser considers them valid, that's fine too + // The important thing is the parser doesn't crash + expect(Array.isArray(choices)).toBe(true); + }); + }); +}); + +// ─── isApprovalQuestion ────────────────────────────────────── + +describe("isApprovalQuestion", () => { + describe("positive: approval / confirmation questions", () => { + it("detects yes/no question starters", () => { + expect(isApprovalQuestion("Shall I proceed?")).toBe(true); + expect(isApprovalQuestion("Should we continue?")).toBe(true); + expect(isApprovalQuestion("Can I delete this file?")).toBe(true); + expect(isApprovalQuestion("Would you like to save?")).toBe(true); + expect(isApprovalQuestion("Do you want to proceed?")).toBe(true); + }); + + it("detects action confirmation keywords", () => { + expect(isApprovalQuestion("Ready to proceed?")).toBe(true); + expect(isApprovalQuestion("Confirm the deployment?")).toBe(true); + expect(isApprovalQuestion("Apply these changes?")).toBe(true); + }); + + it("detects binary choice indicators", () => { + expect(isApprovalQuestion("Continue? [y/n]")).toBe(true); + expect(isApprovalQuestion("Are you sure? yes or no")).toBe(true); + }); + + it("detects 'want me to' / 'like me to' phrases", () => { + expect(isApprovalQuestion("Do you want me to fix this?")).toBe(true); + expect(isApprovalQuestion("Would you like me to refactor?")).toBe(true); + }); + + it("detects short questions ending with ? as approval", () => { + expect(isApprovalQuestion("Looks good?")).toBe(true); + expect(isApprovalQuestion("Correct?")).toBe(true); + }); + + it("detects short non-interrogative questions via heuristic", () => { + // These don't match any approval pattern but are short, end with ?, + // and don't start with an interrogative word → true via heuristic + expect(isApprovalQuestion("Done?")).toBe(true); + expect(isApprovalQuestion("Updated?")).toBe(true); + expect(isApprovalQuestion("All set?")).toBe(true); + }); + + it("detects short non-interrogative questions with trailing whitespace", () => { + // Trailing whitespace bypasses /\?$/ pattern, reaching the heuristic + expect(isApprovalQuestion("Done? ")).toBe(true); + expect(isApprovalQuestion("Fixed? ")).toBe(true); + }); + + it("rejects short interrogative questions with trailing whitespace", () => { + // Trailing whitespace makes heuristic reachable; interrogative word → false + expect(isApprovalQuestion("When? ")).toBe(false); + expect(isApprovalQuestion("Where? ")).toBe(false); + }); + }); + + describe("negative: non-approval questions", () => { + it("rejects open-ended what/which/how questions", () => { + expect(isApprovalQuestion("What is the file path?")).toBe(false); + expect(isApprovalQuestion("Which option do you prefer?")).toBe(false); + expect(isApprovalQuestion("How should I implement this?")).toBe(false); + expect(isApprovalQuestion("Where should I put the file?")).toBe(false); + }); + + it("rejects questions with numbered lists", () => { + const text = + "Which approach?\n1. Use REST API\n2. Use GraphQL\n3. Use gRPC"; + expect(isApprovalQuestion(text)).toBe(false); + }); + + it("rejects questions with multi-digit numbered lists", () => { + // Uses 10+ digit numbers to bypass single-digit negative pattern /[1-9][.)]/ + // and "Here are" avoids "Pick one" matching /pick (?:one|from|between)/ + const text = + "Here are the approaches:\n10. First approach\n11. Second approach\n12. Third approach"; + expect(isApprovalQuestion(text)).toBe(false); + }); + + it("rejects 'select/choose an option' prompts", () => { + expect(isApprovalQuestion("Please select an option")).toBe(false); + expect(isApprovalQuestion("Choose an option below")).toBe(false); + }); + + it("rejects open-ended input requests", () => { + expect(isApprovalQuestion("Enter a name for the file")).toBe(false); + expect(isApprovalQuestion("Provide the API endpoint")).toBe(false); + }); + + it("rejects 'describe/explain' requests", () => { + expect(isApprovalQuestion("Describe what happened")).toBe(false); + expect(isApprovalQuestion("Explain the error")).toBe(false); + }); + + it("rejects questions asking for specific info", () => { + expect(isApprovalQuestion("What do you think about this approach?")).toBe( + false, + ); + expect(isApprovalQuestion("Any suggestions for improvement?")).toBe( + false, + ); + }); + }); + + describe("threshold parameter", () => { + it("uses default SHORT_QUESTION_THRESHOLD (100)", () => { + expect(SHORT_QUESTION_THRESHOLD).toBe(100); + }); + + it("respects custom threshold", () => { + // A 50-char "question" that ends with ? and isn't interrogative + const shortQ = "Proceed?"; + expect(isApprovalQuestion(shortQ, 5)).toBe(true); // below threshold → heuristic fires + // Even with threshold=5, the text matches approval patterns so still true + }); + }); + + describe("edge cases", () => { + it("returns false for long non-matching text", () => { + // Text that doesn't match any approval or negative pattern + // and is longer than SHORT_QUESTION_THRESHOLD + const longText = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; + expect(isApprovalQuestion(longText)).toBe(false); + }); + + it("returns false for statement without approval patterns", () => { + const text = "The data has been loaded into memory for analysis."; + expect(isApprovalQuestion(text)).toBe(false); + }); + + it("detects ASCII art boxes as multi-choice", () => { + const text = + "Pick one:\n┌──────┐\n│ A │\n├──────┤\n│ B │\n└──────┘"; + expect(isApprovalQuestion(text)).toBe(false); + }); + + it("detects bracketed choice patterns as multi-choice", () => { + const text = "Choose: [Approve] [Reject]"; + expect(isApprovalQuestion(text)).toBe(false); + }); + }); +}); diff --git a/tasksync-chat/src/webview/choiceParser.ts b/tasksync-chat/src/webview/choiceParser.ts new file mode 100644 index 0000000..b2d234d --- /dev/null +++ b/tasksync-chat/src/webview/choiceParser.ts @@ -0,0 +1,361 @@ +import type { ParsedChoice } from "./webviewTypes"; + +/** + * Default threshold for "short question" heuristic in approval detection. + */ +export const SHORT_QUESTION_THRESHOLD = 100; // chars + +/** Maximum display length for a choice label button. */ +export const CHOICE_LABEL_MAX_LENGTH = 40; + +/** Truncation point for long labels (leaves room for "..."). */ +const CHOICE_LABEL_TRUNCATE_AT = CHOICE_LABEL_MAX_LENGTH - 3; + +/** + * Parse choices from a question text. + * Detects numbered lists (1. 2. 3.), lettered options (A. B. C.), and Option X: patterns. + * Only detects choices near the LAST question mark "?" to avoid false positives from + * earlier numbered/lettered content in the text. + * + * @param text - The question text to parse + * @returns Array of parsed choices, empty if no choices detected + */ +export function parseChoices(text: string): ParsedChoice[] { + const choices: ParsedChoice[] = []; + let match; + + // Search the ENTIRE text for numbered/lettered lists, not just after the last "?" + // The previous approach failed when examples within the text contained "?" characters + // (e.g., "Example: What's your favorite language?") + + // Strategy: Find the FIRST major numbered/lettered list that starts early in the text + // These are the actual choices, not examples or descriptions within the text + + // Split entire text into lines for multi-line patterns + const lines = text.split("\n"); + + // Pattern 1: Numbered options - lines starting with "1." or "1)" through 9 + // Also match bold numbered options like "**1. Option**" + const numberedLinePattern = /^\s*\*{0,2}(\d+)[.)]\s*\*{0,2}\s*(.+)$/; + const numberedLines: { + index: number; + num: string; + numValue: number; + text: string; + }[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(numberedLinePattern); + if (m && m[2].trim().length >= 3) { + // Clean up markdown bold markers from text + const cleanText = m[2].replace(/\*\*/g, "").trim(); + numberedLines.push({ + index: i, + num: m[1], + numValue: parseInt(m[1], 10), + text: cleanText, + }); + } + } + + // Find the FIRST contiguous list (which contains the main choices) + // Previously used LAST list which missed choices when examples appeared later in text + if (numberedLines.length >= 2) { + // Find all list boundaries by detecting number restarts + const listBoundaries: number[] = [0]; // First list starts at index 0 + + for (let i = 1; i < numberedLines.length; i++) { + const prevNum = numberedLines[i - 1].numValue; + const currNum = numberedLines[i].numValue; + const lineGap = numberedLines[i].index - numberedLines[i - 1].index; + + // Detect a new list if: + // 1. Number resets (e.g., 2 -> 1, or any case where current < previous) + // 2. Large gap between lines (> 5 lines typically means different section) + if (currNum <= prevNum || lineGap > 5) { + listBoundaries.push(i); + } + } + + // Get the FIRST list (the main choices list) + // The first numbered list is typically the actual choices + // Later lists are often examples or descriptions within each choice + const firstListEnd = + listBoundaries.length > 1 ? listBoundaries[1] : numberedLines.length; + const firstGroup = numberedLines.slice(0, firstListEnd); + + if (firstGroup.length >= 2) { + for (const m of firstGroup) { + const cleanText = m.text.replace(/[?!]+$/, "").trim(); + const displayText = + cleanText.length > CHOICE_LABEL_MAX_LENGTH + ? cleanText.substring(0, CHOICE_LABEL_TRUNCATE_AT) + "..." + : cleanText; + choices.push({ + label: displayText, + value: m.num, + shortLabel: m.num, + }); + } + return choices; + } + } + + // Pattern 1b: Inline numbered lists "1. option 2. option 3. option" or "1 - option 2 - option" + const inlineNumberedPattern = + /(\d+)(?:[.):]|\s+-)\s+([^0-9]+?)(?=\s+\d+(?:[.):]|\s+-)|$)/g; + const inlineNumberedMatches: { num: string; text: string }[] = []; + + // Only try inline if no multi-line matches found + // Use full text converted to single line + const singleLine = text.replace(/\n/g, " "); + while ((match = inlineNumberedPattern.exec(singleLine)) !== null) { + const optionText = match[2].trim(); + if (optionText.length >= 3) { + inlineNumberedMatches.push({ num: match[1], text: optionText }); + } + } + + if (inlineNumberedMatches.length >= 2) { + for (const m of inlineNumberedMatches) { + const cleanText = m.text.replace(/[?!]+$/, "").trim(); + const displayText = + cleanText.length > CHOICE_LABEL_MAX_LENGTH + ? cleanText.substring(0, CHOICE_LABEL_TRUNCATE_AT) + "..." + : cleanText; + choices.push({ + label: displayText, + value: m.num, + shortLabel: m.num, + }); + } + return choices; + } + + // Pattern 2: Lettered options - lines starting with "A." or "A)" or "**A)" through Z + // Also match bold lettered options like "**A) Option**" + // FIX: Search entire text, not just after question mark + const letteredLinePattern = /^\s*\*{0,2}([A-Za-z])[.)]\s*\*{0,2}\s*(.+)$/; + const letteredLines: { index: number; letter: string; text: string }[] = []; + + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(letteredLinePattern); + if (m && m[2].trim().length >= 3) { + // Clean up markdown bold markers from text + const cleanText = m[2].replace(/\*\*/g, "").trim(); + letteredLines.push({ + index: i, + letter: m[1].toUpperCase(), + text: cleanText, + }); + } + } + + if (letteredLines.length >= 2) { + // Find all list boundaries by detecting letter restarts or gaps + const listBoundaries: number[] = [0]; + + for (let i = 1; i < letteredLines.length; i++) { + const gap = letteredLines[i].index - letteredLines[i - 1].index; + // Detect new list if gap > 3 lines + if (gap > 3) { + listBoundaries.push(i); + } + } + + // Get the FIRST list (the main choices list) + const firstListEnd = + listBoundaries.length > 1 ? listBoundaries[1] : letteredLines.length; + const firstGroup = letteredLines.slice(0, firstListEnd); + + if (firstGroup.length >= 2) { + for (const m of firstGroup) { + const cleanText = m.text.replace(/[?!]+$/, "").trim(); + const displayText = + cleanText.length > CHOICE_LABEL_MAX_LENGTH + ? cleanText.substring(0, CHOICE_LABEL_TRUNCATE_AT) + "..." + : cleanText; + choices.push({ + label: displayText, + value: m.letter, + shortLabel: m.letter, + }); + } + return choices; + } + } + + // Pattern 2b: Inline lettered "A. option B. option C. option" + // Only match single uppercase letters to avoid false positives + const inlineLetteredPattern = /\b([A-Z])[.)]\s+([^A-Z]+?)(?=\s+[A-Z][.)]|$)/g; + const inlineLetteredMatches: { letter: string; text: string }[] = []; + + while ((match = inlineLetteredPattern.exec(singleLine)) !== null) { + const optionText = match[2].trim(); + if (optionText.length >= 3) { + inlineLetteredMatches.push({ letter: match[1], text: optionText }); + } + } + + if (inlineLetteredMatches.length >= 2) { + for (const m of inlineLetteredMatches) { + const cleanText = m.text.replace(/[?!]+$/, "").trim(); + const displayText = + cleanText.length > CHOICE_LABEL_MAX_LENGTH + ? cleanText.substring(0, CHOICE_LABEL_TRUNCATE_AT) + "..." + : cleanText; + choices.push({ + label: displayText, + value: m.letter, + shortLabel: m.letter, + }); + } + return choices; + } + + // Pattern 3: "Option A:" or "Option 1:" style + // Search entire text for this pattern + const optionPattern = + /option\s+([A-Za-z1-9])\s*:\s*([^\n]+?)(?=\s*Option\s+[A-Za-z1-9]|\s*$|\n)/gi; + const optionMatches: { id: string; text: string }[] = []; + + while ((match = optionPattern.exec(text)) !== null) { + const optionText = match[2].trim(); + if (optionText.length >= 3) { + optionMatches.push({ id: match[1].toUpperCase(), text: optionText }); + } + } + + if (optionMatches.length >= 2) { + for (const m of optionMatches) { + const cleanText = m.text.replace(/[?!]+$/, "").trim(); + const displayText = + cleanText.length > CHOICE_LABEL_MAX_LENGTH + ? cleanText.substring(0, CHOICE_LABEL_TRUNCATE_AT) + "..." + : cleanText; + choices.push({ + label: displayText, + value: `Option ${m.id}`, + shortLabel: m.id, + }); + } + return choices; + } + + return choices; +} + +/** + * Detect if a question is an approval/confirmation type that warrants quick action buttons. + * Uses NLP patterns to identify yes/no questions, permission requests, and confirmations. + * + * @param text - The question text to analyze + * @param shortQuestionThreshold - Character threshold for "short question" heuristic (default: 100) + * @returns true if the question is an approval-type question + */ +export function isApprovalQuestion( + text: string, + shortQuestionThreshold: number = SHORT_QUESTION_THRESHOLD, +): boolean { + const lowerText = text.toLowerCase(); + + // NEGATIVE patterns - questions that require specific input (NOT approval questions) + const requiresSpecificInput = [ + // Generic "select/choose an option" prompts - these need specific choice, not yes/no + /please (?:select|choose|pick) (?:an? )?option/i, + /select (?:an? )?option/i, + // Open-ended requests for feedback/information + /let me know/i, + /tell me (?:what|how|when|if|about)/i, + /waiting (?:for|on) (?:your|the)/i, + /ready to (?:hear|see|get|receive)/i, + // Questions asking for specific information + /what (?:is|are|should|would)/i, + /which (?:one|file|option|method|approach)/i, + /where (?:should|would|is|are)/i, + /how (?:should|would|do|can)/i, + /when (?:should|would)/i, + /who (?:should|would)/i, + // Questions asking for names, values, content + /(?:enter|provide|specify|give|type|input|write)\s+(?:a|the|your)/i, + /what.*(?:name|value|path|url|content|text|message)/i, + /please (?:enter|provide|specify|give|type)/i, + // Open-ended questions + /describe|explain|elaborate|clarify/i, + /tell me (?:about|more|how)/i, + /what do you (?:think|want|need|prefer)/i, + /any (?:suggestions|recommendations|preferences|thoughts)/i, + // Questions with multiple choice indicators (not binary) + /choose (?:from|between|one of)/i, + /select (?:from|one of|which)/i, + /pick (?:one|from|between)/i, + // Numbered options (1. 2. 3. or 1) 2) 3)) + /\n\s*[1-9][.)]\s+\S/i, + // Lettered options (A. B. C. or a) b) c) or Option A/B/C) + /\n\s*[a-d][.)]\s+\S/i, + /option\s+[a-d]\s*:/i, + // "Would you like me to:" followed by list + /would you like (?:me to|to):\s*\n/i, + // ASCII art boxes/mockups (common patterns) + /[┌├└│┐┤┘─╔╠╚║╗╣╝═]/, + /\[.+\]\s+\[.+\]/i, // Multiple bracketed options like [Approve] [Reject] + // "Something else?" at the end of a list typically means multi-choice + /\d+[.)]\s+something else\??/i, + ]; + + // Check if question requires specific input - if so, NOT an approval question + for (const pattern of requiresSpecificInput) { + if (pattern.test(lowerText)) { + return false; + } + } + + // Also check for numbered lists anywhere in text (strong indicator of multi-choice) + const numberedListCount = (text.match(/\n\s*\d+[.)]\s+/g) || []).length; + if (numberedListCount >= 2) { + return false; // Multiple numbered items = multi-choice question + } + + // POSITIVE patterns - approval/confirmation questions + const approvalPatterns = [ + // Direct yes/no question patterns + /^(?:shall|should|can|could|may|would|will|do|does|did|is|are|was|were|have|has|had)\s+(?:i|we|you|it|this|that)\b/i, + // Permission/confirmation phrases + /(?:proceed|continue|go ahead|start|begin|execute|run|apply|commit|save|delete|remove|create|add|update|modify|change|overwrite|replace)/i, + /(?:ok|okay|alright|ready|confirm|approve|accept|allow|enable|disable|skip|ignore|dismiss|close|cancel|abort|stop|exit|quit)/i, + // Question endings that suggest yes/no + /\?$/, + /(?:right|correct|yes|no)\s*\?$/i, + /(?:is that|does that|would that|should that)\s+(?:ok|okay|work|help|be\s+(?:ok|fine|good|acceptable))/i, + // Explicit approval requests + /(?:do you want|would you like|shall i|should i|can i|may i|could i)/i, + /(?:want me to|like me to|need me to)/i, + /(?:approve|confirm|authorize|permit|allow)\s+(?:this|the|these)/i, + // Binary choice indicators + /(?:yes or no|y\/n|yes\/no|\[y\/n\]|\(y\/n\))/i, + // Action confirmation patterns + /(?:are you sure|do you confirm|please confirm|confirm that)/i, + /(?:this will|this would|this is going to)/i, + ]; + + // Check if any approval pattern matches + for (const pattern of approvalPatterns) { + if (pattern.test(lowerText)) { + return true; + } + } + + // Additional heuristic: short questions ending with ? are likely yes/no + if ( + lowerText.length < shortQuestionThreshold && + lowerText.trim().endsWith("?") + ) { + // But exclude questions with interrogative words that typically need specific answers + const interrogatives = + /^(?:what|which|where|when|why|how|who|whom|whose)\b/i; + if (!interrogatives.test(lowerText.trim())) { + return true; + } + } + + return false; +} diff --git a/tasksync-chat/src/webview/fileHandlers.ts b/tasksync-chat/src/webview/fileHandlers.ts new file mode 100644 index 0000000..8edeb97 --- /dev/null +++ b/tasksync-chat/src/webview/fileHandlers.ts @@ -0,0 +1,583 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + FILE_EXCLUSION_PATTERNS, + FILE_SEARCH_EXCLUSION_PATTERNS, + formatExcludePattern, +} from "../constants/fileExclusions"; +import { MAX_IMAGE_PASTE_BYTES } from "../constants/remoteConstants"; +import { ContextReferenceType } from "../context"; +import type { + AttachmentInfo, + FileSearchResult, + P, + ToWebviewMessage, +} from "./webviewTypes"; +import { generateId, getFileIcon } from "./webviewUtils"; + +export async function handleAddAttachment(p: P): Promise { + try { + const excludePattern = formatExcludePattern(FILE_EXCLUSION_PATTERNS); + const files = await vscode.workspace.findFiles( + "**/*", + excludePattern, + p._MAX_FOLDER_SEARCH_RESULTS, + ); + + if (files.length === 0) { + vscode.window.showInformationMessage("No files found in workspace"); + return; + } + + const items: (vscode.QuickPickItem & { uri: vscode.Uri })[] = files + .map((uri) => { + const relativePath = vscode.workspace.asRelativePath(uri); + const fileName = path.basename(uri.fsPath); + return { + label: `$(file) ${fileName}`, + description: relativePath, + uri: uri, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selected = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: "Select files to attach", + matchOnDescription: true, + }); + + if (selected && selected.length > 0) { + for (const item of selected) { + const labelMatch = item.label.match(/\$\([^)]+\)\s*(.+)/); + const cleanName = labelMatch ? labelMatch[1] : item.label; + const attachment: AttachmentInfo = { + id: generateId("att"), + name: cleanName, + uri: item.uri.toString(), + }; + p._attachments.push(attachment); + } + updateAttachmentsUI(p); + } + } catch (e) { + console.error("[TaskSync] Failed to add attachment:", e); + vscode.window.showErrorMessage("Failed to add attachment"); + } +} + +/** + * Handle removing attachment. + */ +export function handleRemoveAttachment(p: P, attachmentId: string): void { + p._attachments = p._attachments.filter( + (a: AttachmentInfo) => a.id !== attachmentId, + ); + updateAttachmentsUI(p); +} + +/** + * Search registered LM tools matching a query. + * Shared by local handleSearchFiles and remote searchFilesForRemote. + */ +export function searchToolsForAutocomplete(query: string): FileSearchResult[] { + const queryLower = query.toLowerCase(); + return vscode.lm.tools + .filter((t) => t.name.toLowerCase().includes(queryLower)) + .slice(0, 20) + .map((t) => ({ + name: t.name, + path: t.description, + uri: `tool://${t.name}`, + icon: "tools", + isTool: true, + })); +} + +/** + * Handle file search for autocomplete (also includes #terminal, #problems context and tools). + */ +export async function handleSearchFiles(p: P, query: string): Promise { + try { + const queryLower = query.toLowerCase(); + const cacheKey = queryLower || "__all__"; + // Context suggestions (#terminal, #problems) + const contextResults: FileSearchResult[] = []; + + if (!queryLower || "terminal".includes(queryLower)) { + const commands = + p._contextManager.terminal.formatCommandListForAutocomplete(); + const description = + commands.length > 0 + ? `${commands.length} recent commands` + : "No commands yet"; + contextResults.push({ + name: "terminal", + path: description, + uri: "context://terminal", + icon: "terminal", + isFolder: false, + isContext: true, + }); + } + + if (!queryLower || "problems".includes(queryLower)) { + const problemsInfo = p._contextManager.problems.formatForAutocomplete(); + contextResults.push({ + name: "problems", + path: problemsInfo.description, + uri: "context://problems", + icon: "error", + isFolder: false, + isContext: true, + }); + } + + const cached = p._fileSearchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < p._FILE_CACHE_TTL_MS) { + const cachedToolResults = searchToolsForAutocomplete(query); + p._view?.webview.postMessage({ + type: "fileSearchResults", + files: [...contextResults, ...cachedToolResults, ...cached.results], + } satisfies ToWebviewMessage); + return; + } + + const excludePattern = formatExcludePattern(FILE_SEARCH_EXCLUSION_PATTERNS); + const allFiles = await vscode.workspace.findFiles( + "**/*", + excludePattern, + p._MAX_FILE_SEARCH_RESULTS, + ); + + const seenFolders = new Set(); + const folderResults: FileSearchResult[] = []; + + for (const uri of allFiles) { + const relativePath = vscode.workspace.asRelativePath(uri); + const dirPath = path.dirname(relativePath); + + if (dirPath && dirPath !== "." && !seenFolders.has(dirPath)) { + seenFolders.add(dirPath); + const folderName = path.basename(dirPath); + + if ( + !queryLower || + folderName.toLowerCase().includes(queryLower) || + dirPath.toLowerCase().includes(queryLower) + ) { + const workspaceFolder = + vscode.workspace.getWorkspaceFolder(uri)?.uri ?? + vscode.workspace.workspaceFolders?.[0]?.uri; + if (!workspaceFolder) continue; + folderResults.push({ + name: folderName, + path: dirPath, + uri: vscode.Uri.joinPath(workspaceFolder, dirPath).toString(), + icon: "folder", + isFolder: true, + }); + } + } + } + + const fileResults: FileSearchResult[] = allFiles + .map((uri) => { + const relativePath = vscode.workspace.asRelativePath(uri); + const fileName = path.basename(uri.fsPath); + return { + name: fileName, + path: relativePath, + uri: uri.toString(), + icon: getFileIcon(fileName), + isFolder: false, + }; + }) + .filter( + (file) => + !queryLower || + file.name.toLowerCase().includes(queryLower) || + file.path.toLowerCase().includes(queryLower), + ); + + const fileAndFolderResults = [...folderResults, ...fileResults] + .sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + const aExact = a.name.toLowerCase().startsWith(queryLower); + const bExact = b.name.toLowerCase().startsWith(queryLower); + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return a.name.localeCompare(b.name); + }) + .slice(0, 48); + + // Tool results (LM tools matching query) + const toolResults = searchToolsForAutocomplete(query); + + const allResults = [ + ...contextResults, + ...toolResults, + ...fileAndFolderResults, + ]; + + p._fileSearchCache.set(cacheKey, { + results: fileAndFolderResults, + timestamp: Date.now(), + }); + if (p._fileSearchCache.size > 20) { + const firstKey = p._fileSearchCache.keys().next().value; + if (firstKey) p._fileSearchCache.delete(firstKey); + } + + p._view?.webview.postMessage({ + type: "fileSearchResults", + files: allResults, + } satisfies ToWebviewMessage); + } catch (error) { + console.error("[TaskSync] File search error:", error); + p._view?.webview.postMessage({ + type: "fileSearchResults", + files: [], + } satisfies ToWebviewMessage); + } +} + +/** + * Handle saving pasted/dropped image. + */ +export async function handleSaveImage( + p: P, + dataUrl: string, + mimeType: string, +): Promise { + try { + const base64Match = dataUrl.match(/^data:[^;]+;base64,(.+)$/); + if (!base64Match) { + vscode.window.showWarningMessage("Invalid image format"); + return; + } + + const base64Data = base64Match[1]; + const estimatedSize = Math.ceil(base64Data.length * 0.75); + if (estimatedSize > MAX_IMAGE_PASTE_BYTES) { + const sizeMB = (estimatedSize / (1024 * 1024)).toFixed(2); + vscode.window.showWarningMessage( + `Image too large (~${sizeMB}MB). Max ${MAX_IMAGE_PASTE_BYTES / (1024 * 1024)}MB.`, + ); + return; + } + + const buffer = Buffer.from(base64Data, "base64"); + if (buffer.length > MAX_IMAGE_PASTE_BYTES) { + const sizeMB = (buffer.length / (1024 * 1024)).toFixed(2); + vscode.window.showWarningMessage( + `Image too large (${sizeMB}MB). Max ${MAX_IMAGE_PASTE_BYTES / (1024 * 1024)}MB.`, + ); + return; + } + + const validMimeTypes = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + ]; + if (!validMimeTypes.includes(mimeType)) { + vscode.window.showWarningMessage(`Unsupported image type: ${mimeType}`); + return; + } + + const extMap: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + }; + const ext = extMap[mimeType] || ".png"; + + const storageUri = p._context.storageUri || p._context.globalStorageUri; + if (!storageUri) { + throw new Error("VS Code extension storage URI not available."); + } + + const tempDir = path.join(storageUri.fsPath, "temp-images"); + if (!fs.existsSync(tempDir)) { + await fs.promises.mkdir(tempDir, { recursive: true }); + } + + const existingImages = p._attachments.filter( + (a: AttachmentInfo) => a.isTemporary, + ).length; + let fileName = + existingImages === 0 + ? `image-pasted${ext}` + : `image-pasted-${existingImages}${ext}`; + let filePath = path.join(tempDir, fileName); + + let counter = existingImages; + while (fs.existsSync(filePath)) { + counter++; + fileName = `image-pasted-${counter}${ext}`; + filePath = path.join(tempDir, fileName); + } + + await fs.promises.writeFile(filePath, buffer); + + const attachment: AttachmentInfo = { + id: generateId("img"), + name: fileName, + uri: vscode.Uri.file(filePath).toString(), + isTemporary: true, + }; + + p._attachments.push(attachment); + p._view?.webview.postMessage({ + type: "imageSaved", + attachment, + } satisfies ToWebviewMessage); + updateAttachmentsUI(p); + } catch (error) { + console.error("[TaskSync] Failed to save image:", error); + vscode.window.showErrorMessage("Failed to save pasted image"); + } +} + +/** + * Handle adding file reference from autocomplete. + */ +export function handleAddFileReference(p: P, file: FileSearchResult): void { + const attachment: AttachmentInfo = { + id: generateId(file.isFolder ? "folder" : "file"), + name: file.name, + uri: file.uri, + isFolder: file.isFolder, + isTextReference: true, + }; + p._attachments.push(attachment); + updateAttachmentsUI(p); +} + +/** + * Update attachments UI. + */ +export function updateAttachmentsUI(p: P): void { + p._view?.webview.postMessage({ + type: "updateAttachments", + attachments: p._attachments, + } satisfies ToWebviewMessage); +} + +/** + * Open an external URL from webview using a strict protocol allowlist. + */ +export function handleOpenExternalLink(url: string): void { + if (!url) return; + try { + const parsed = vscode.Uri.parse(url); + const allowedSchemes = ["http", "https", "mailto"]; + if (!allowedSchemes.includes(parsed.scheme)) { + vscode.window.showWarningMessage( + `Unsupported link protocol: ${parsed.scheme}`, + ); + return; + } + void vscode.env.openExternal(parsed); + } catch (error) { + console.error("[TaskSync] Failed to open external link:", error); + vscode.window.showWarningMessage("Unable to open external link"); + } +} + +/** + * Copy plain text to the system clipboard. + */ +export async function handleCopyToClipboard(text: string): Promise { + if (typeof text !== "string" || text.length === 0) return; + try { + await vscode.env.clipboard.writeText(text); + } catch (error) { + console.error("[TaskSync] Failed to copy text to clipboard:", error); + vscode.window.showWarningMessage("Unable to copy content to clipboard"); + } +} + +/** + * Open a file link from webview and reveal requested line or line range. + */ +export async function handleOpenFileLink(target: string): Promise { + if (!target) return; + + const { parseFileLinkTarget, resolveFileLinkUri } = await import( + "./webviewUtils" + ); + const parsedTarget = parseFileLinkTarget(target); + if (!parsedTarget.filePath) { + vscode.window.showWarningMessage("File link does not contain a valid path"); + return; + } + + const fileUri = resolveFileLinkUri(parsedTarget.filePath); + if (!fileUri) { + vscode.window.showWarningMessage( + `File not found: ${parsedTarget.filePath}`, + ); + return; + } + + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + }); + + if (parsedTarget.startLine !== null) { + const maxLine = Math.max(document.lineCount - 1, 0); + const startLine = Math.min( + Math.max(parsedTarget.startLine - 1, 0), + maxLine, + ); + const requestedEnd = parsedTarget.endLine ?? parsedTarget.startLine; + const endLine = Math.min(Math.max(requestedEnd - 1, startLine), maxLine); + const endCharacter = document.lineAt(endLine).range.end.character; + const range = new vscode.Range(startLine, 0, endLine, endCharacter); + editor.selection = new vscode.Selection( + startLine, + 0, + endLine, + endCharacter, + ); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + } + } catch (error) { + console.error("[TaskSync] Failed to open file link:", error); + vscode.window.showWarningMessage( + `Unable to open file: ${parsedTarget.filePath}`, + ); + } +} + +/** + * Handle searching context references. + */ +export async function handleSearchContext(p: P, query: string): Promise { + try { + const suggestions = await p._contextManager.getContextSuggestions(query); + p._view?.webview.postMessage({ + type: "contextSearchResults", + suggestions: suggestions.map( + (s: { + type: string; + label: string; + description: string; + detail?: string; + }) => ({ + type: s.type, + label: s.label, + description: s.description, + detail: s.detail ?? "", + }), + ), + } satisfies ToWebviewMessage); + } catch (error) { + console.error("[TaskSync] Error searching context:", error); + p._view?.webview.postMessage({ + type: "contextSearchResults", + suggestions: [], + } satisfies ToWebviewMessage); + } +} + +/** + * Handle selecting a context reference to add as attachment. + */ +export async function handleSelectContextReference( + p: P, + contextType: string, + options?: Record, +): Promise { + try { + const reference = await p._contextManager.getContextContent( + contextType as ContextReferenceType, + options, + ); + + if (reference) { + const contextAttachment: AttachmentInfo = { + id: reference.id, + name: reference.label, + uri: `context://${reference.type}/${reference.id}`, + isTextReference: true, + }; + p._attachments.push(contextAttachment); + updateAttachmentsUI(p); + + p._view?.webview.postMessage({ + type: "contextReferenceAdded", + reference: { + id: reference.id, + type: reference.type, + label: reference.label, + content: reference.content, + }, + } satisfies ToWebviewMessage); + } else { + const emptyId = `ctx_empty_${Date.now()}`; + const friendlyType = contextType.replace(":", " "); + const contextAttachment: AttachmentInfo = { + id: emptyId, + name: `#${friendlyType} (no content)`, + uri: `context://${contextType}/${emptyId}`, + isTextReference: true, + }; + p._attachments.push(contextAttachment); + updateAttachmentsUI(p); + vscode.window.showInformationMessage( + `No ${contextType} content available yet`, + ); + } + } catch (error) { + console.error("[TaskSync] Error selecting context reference:", error); + vscode.window.showErrorMessage(`Failed to get ${contextType} content`); + } +} + +/** + * Clean up temporary image files from disk by URI list. + */ +export function cleanupTempImagesByUri(uris: string[]): void { + for (const uri of uris) { + try { + const filePath = vscode.Uri.parse(uri).fsPath; + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + console.error("[TaskSync] Failed to cleanup temp image:", error); + } + } +} + +/** + * Clean up temporary images from tool call entries. + */ +export function cleanupTempImagesFromEntries( + entries: { attachments?: AttachmentInfo[] }[], +): void { + const tempUris: string[] = []; + for (const entry of entries) { + if (entry.attachments) { + for (const att of entry.attachments) { + if (att.isTemporary && att.uri) { + tempUris.push(att.uri); + } + } + } + } + if (tempUris.length > 0) { + cleanupTempImagesByUri(tempUris); + } +} diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts new file mode 100644 index 0000000..932d497 --- /dev/null +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -0,0 +1,254 @@ +/** + * Lifecycle handlers extracted from webviewProvider.ts. + * Contains HTML generation, dispose, resolveWebviewView setup, and startNewSession. + */ +import * as fs from "fs"; +import * as vscode from "vscode"; + +import * as fileH from "./fileHandlers"; +import type { FromWebviewMessage, P, ToWebviewMessage } from "./webviewTypes"; +import { debugLog, getNonce } from "./webviewUtils"; + +/** Cached HTML body template to avoid repeated synchronous I/O. */ +let cachedBodyTemplate: string | undefined; + +/** + * Preload the shared HTML body template asynchronously during activation. + * Call this early to avoid a synchronous `readFileSync` on first webview resolve. + */ +export async function preloadBodyTemplate( + extensionUri: vscode.Uri, +): Promise { + if (cachedBodyTemplate) return; + const templatePath = vscode.Uri.joinPath( + extensionUri, + "media", + "webview-body.html", + ).fsPath; + cachedBodyTemplate = await fs.promises.readFile(templatePath, "utf8"); +} + +/** + * Generate HTML content for the webview panel. + */ +export function getHtmlContent( + extensionUri: vscode.Uri, + webview: vscode.Webview, +): string { + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "main.css"), + ); + const markdownLinksScriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "markdownLinks.js"), + ); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "webview.js"), + ); + const codiconsUri = webview.asWebviewUri( + vscode.Uri.joinPath( + extensionUri, + "node_modules", + "@vscode", + "codicons", + "dist", + "codicon.css", + ), + ); + const logoUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "TS-logo.svg"), + ); + const notificationSoundUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "notification.wav"), + ); + const nonce = getNonce(); + + // Read shared HTML body template (SSOT) — preloaded at activation, sync fallback for safety + const templatePath = vscode.Uri.joinPath( + extensionUri, + "media", + "webview-body.html", + ).fsPath; + if (!cachedBodyTemplate) { + cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); + } + let bodyHtml = cachedBodyTemplate; + + bodyHtml = bodyHtml + .replace(/\{\{LOGO_URI\}\}/g, logoUri.toString()) + .replace(/\{\{TITLE\}\}/g, "Let's build") + .replace(/\{\{SUBTITLE\}\}/g, "Sync your tasks, automate your workflow"); + + return ` + + + + + + + + TaskSync Chat + + + + ${bodyHtml} + + + +`; +} + +/** + * Set up a webview view with event handlers. + */ +export function setupWebviewView(p: P, webviewView: vscode.WebviewView): void { + debugLog("[TaskSync] setupWebviewView — initializing webview"); + p._view = webviewView; + p._webviewReady = false; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [p._extensionUri], + }; + + webviewView.webview.html = getHtmlContent( + p._extensionUri, + webviewView.webview, + ); + + // Restore session timer display if timer is already running + if (p._sessionStartTime !== null || p._sessionFrozenElapsed !== null) { + p._updateViewTitle(); + if (p._sessionStartTime !== null && p._sessionFrozenElapsed === null) { + p._startSessionTimerInterval(); + } + } + + webviewView.webview.onDidReceiveMessage( + (message: FromWebviewMessage) => { + p._handleWebviewMessage(message); + }, + undefined, + p._disposables, + ); + + webviewView.onDidDispose( + () => { + debugLog("[TaskSync] webviewView disposed — cleaning up"); + p._webviewReady = false; + p._view = undefined; + p._fileSearchCache.clear(); + p.saveCurrentSessionToHistory(); + }, + null, + p._disposables, + ); + + webviewView.onDidChangeVisibility( + () => { + if (!webviewView.visible) { + debugLog("[TaskSync] webviewView hidden — saving session to history"); + p.saveCurrentSessionToHistory(); + } + }, + null, + p._disposables, + ); +} + +/** + * Dispose all provider resources. + */ +export function disposeProvider(p: P): void { + debugLog("[TaskSync] disposeProvider — disposing all resources"); + p.saveCurrentSessionToHistory(); + + // Stop remote server if running + if (p._remoteServer) { + p._remoteServer.stop(); + } + + if (p._queueSaveTimer) { + clearTimeout(p._queueSaveTimer); + p._queueSaveTimer = null; + } + if (p._historySaveTimer) { + clearTimeout(p._historySaveTimer); + p._historySaveTimer = null; + } + + p._fileSearchCache.clear(); + p._currentSessionCallsMap.clear(); + + // Reject all pending requests so callers don't hang forever + for (const [id, resolve] of p._pendingRequests) { + debugLog(`disposeProvider — rejecting pending request ${id}`); + resolve({ value: "[Extension disposed]", queue: false, attachments: [] }); + } + p._pendingRequests.clear(); + + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + p._stopSessionTimerInterval(); + fileH.cleanupTempImagesFromEntries(p._currentSessionCalls); + + p._currentSessionCalls = []; + p._attachments = []; + p._disposables.forEach((d: vscode.Disposable) => d.dispose()); + p._disposables = []; + p._view = undefined; +} + +/** + * Start a new session: save history, clean up, and reset state. + */ +export function startNewSession(p: P): void { + debugLog( + `[TaskSync] startNewSession — currentToolCallId: ${p._currentToolCallId}, sessionCalls: ${p._currentSessionCalls.length}, aiTurnActive: ${p._aiTurnActive}, sessionTerminated: ${p._sessionTerminated}`, + ); + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + if (p._currentToolCallId) { + debugLog( + `[TaskSync] startNewSession — resolving pending request ${p._currentToolCallId} with [Session reset by user]`, + ); + const resolve = p._pendingRequests.get(p._currentToolCallId); + if (resolve) { + resolve({ + value: "[Session reset by user]", + queue: false, + attachments: [], + }); + } + p._pendingRequests.delete(p._currentToolCallId); + p._currentToolCallId = null; + } + p._consecutiveAutoResponses = 0; + p._autopilotIndex = 0; + + p.saveCurrentSessionToHistory(); + fileH.cleanupTempImagesFromEntries(p._currentSessionCalls); + + p._currentSessionCalls = []; + p._currentSessionCallsMap.clear(); + p._sessionStartTime = null; + p._sessionFrozenElapsed = null; + p._stopSessionTimerInterval(); + p._sessionTerminated = false; + p._sessionWarningShown = false; + p._aiTurnActive = false; + debugLog( + "[TaskSync] startNewSession — session reset complete, aiTurnActive: false, posting clear to webview", + ); + p._updateViewTitle(); + p._updateCurrentSessionUI(); + p._updatePersistedHistoryUI(); + p._view?.webview.postMessage({ type: "clear" } satisfies ToWebviewMessage); + + // Notify remote clients of session reset + p._remoteServer?.broadcast("newSession", {}); +} diff --git a/tasksync-chat/src/webview/messageRouter.ts b/tasksync-chat/src/webview/messageRouter.ts new file mode 100644 index 0000000..22dcd9c --- /dev/null +++ b/tasksync-chat/src/webview/messageRouter.ts @@ -0,0 +1,354 @@ +/** + * Message routing and submit handling extracted from webviewProvider.ts. + * Contains the main webview message dispatcher, ready handler, and submit logic. + */ + +import { isApprovalQuestion, parseChoices } from "./choiceParser"; +import * as fileH from "./fileHandlers"; +import * as queueH from "./queueHandlers"; +import * as settingsH from "./settingsHandlers"; +import type { + AttachmentInfo, + FromWebviewMessage, + P, + QueuedPrompt, + ToolCallEntry, + ToWebviewMessage, +} from "./webviewTypes"; +import { + broadcastToolCallCompleted, + debugLog, + generateId, + hasQueuedItems, + markSessionTerminated, + notifyQueueChanged, +} from "./webviewUtils"; + +/** + * Route incoming webview messages to appropriate handlers. + */ +export function handleWebviewMessage(p: P, message: FromWebviewMessage): void { + debugLog(`[TaskSync] handleWebviewMessage — type: ${message.type}`); + switch (message.type) { + case "submit": + handleSubmit(p, message.value, message.attachments || []); + break; + case "addQueuePrompt": + queueH.handleAddQueuePrompt( + p, + message.prompt, + message.id, + message.attachments || [], + ); + break; + case "removeQueuePrompt": + queueH.handleRemoveQueuePrompt(p, message.promptId); + break; + case "editQueuePrompt": + queueH.handleEditQueuePrompt(p, message.promptId, message.newPrompt); + break; + case "reorderQueue": + queueH.handleReorderQueue(p, message.fromIndex, message.toIndex); + break; + case "toggleQueue": + queueH.handleToggleQueue(p, message.enabled); + break; + case "clearQueue": + queueH.handleClearQueue(p); + break; + case "addAttachment": + fileH.handleAddAttachment(p); + break; + case "removeAttachment": + fileH.handleRemoveAttachment(p, message.attachmentId); + break; + case "removeHistoryItem": + queueH.handleRemoveHistoryItem(p, message.callId); + break; + case "clearPersistedHistory": + queueH.handleClearPersistedHistory(p); + break; + case "openHistoryModal": + p._updatePersistedHistoryUI(); + break; + case "newSession": + p.startNewSession(); + break; + case "searchFiles": + fileH.handleSearchFiles(p, message.query); + break; + case "saveImage": + fileH.handleSaveImage(p, message.data, message.mimeType); + break; + case "addFileReference": + fileH.handleAddFileReference(p, message.file); + break; + case "webviewReady": + handleWebviewReady(p); + break; + case "openSettingsModal": + p._updateSettingsUI(); + break; + case "updateSoundSetting": + settingsH.handleUpdateSoundSetting(p, message.enabled); + break; + case "updateInteractiveApprovalSetting": + settingsH.handleUpdateInteractiveApprovalSetting(p, message.enabled); + break; + case "updateAutopilotSetting": + settingsH.handleUpdateAutopilotSetting(p, message.enabled); + break; + case "updateAutopilotText": + settingsH.handleUpdateAutopilotText(p, message.text); + break; + case "addAutopilotPrompt": + settingsH.handleAddAutopilotPrompt(p, message.prompt); + break; + case "editAutopilotPrompt": + settingsH.handleEditAutopilotPrompt(p, message.index, message.prompt); + break; + case "removeAutopilotPrompt": + settingsH.handleRemoveAutopilotPrompt(p, message.index); + break; + case "reorderAutopilotPrompts": + settingsH.handleReorderAutopilotPrompts( + p, + message.fromIndex, + message.toIndex, + ); + break; + case "addReusablePrompt": + settingsH.handleAddReusablePrompt(p, message.name, message.prompt); + break; + case "editReusablePrompt": + settingsH.handleEditReusablePrompt( + p, + message.id, + message.name, + message.prompt, + ); + break; + case "removeReusablePrompt": + settingsH.handleRemoveReusablePrompt(p, message.id); + break; + case "searchSlashCommands": + settingsH.handleSearchSlashCommands(p, message.query); + break; + case "openExternal": + fileH.handleOpenExternalLink(message.url); + break; + case "openFileLink": + void fileH.handleOpenFileLink(message.target); + break; + case "updateResponseTimeout": + settingsH.handleUpdateResponseTimeout(p, message.value); + break; + case "updateSessionWarningHours": + settingsH.handleUpdateSessionWarningHours(p, message.value); + break; + case "updateMaxConsecutiveAutoResponses": + settingsH.handleUpdateMaxConsecutiveAutoResponses(p, message.value); + break; + case "updateHumanDelaySetting": + settingsH.handleUpdateHumanDelaySetting(p, message.enabled); + break; + case "updateHumanDelayMin": + settingsH.handleUpdateHumanDelayMin(p, message.value); + break; + case "updateHumanDelayMax": + settingsH.handleUpdateHumanDelayMax(p, message.value); + break; + case "updateSendWithCtrlEnterSetting": + settingsH.handleUpdateSendWithCtrlEnterSetting(p, message.enabled); + break; + case "searchContext": + fileH.handleSearchContext(p, message.query); + break; + case "selectContextReference": + fileH.handleSelectContextReference( + p, + message.contextType, + message.options, + ); + break; + case "copyToClipboard": + void fileH.handleCopyToClipboard(message.text); + break; + default: { + const _exhaustiveCheck: never = message; + console.error( + `Unhandled webview message type: ${(_exhaustiveCheck as FromWebviewMessage).type}`, + ); + } + } +} + +/** + * Handle webview ready signal — send initial state and any pending messages. + */ +export function handleWebviewReady(p: P): void { + debugLog( + `[TaskSync] handleWebviewReady — pendingToolCallMessage: ${!!p._pendingToolCallMessage}, currentToolCallId: ${p._currentToolCallId}, pendingRequests: ${p._pendingRequests.size}`, + ); + p._webviewReady = true; + + // Send settings + p._updateSettingsUI(); + // Send initial queue state and current session history + p._updateQueueUI(); + p._updateCurrentSessionUI(); + + // If there's a pending tool call message that was never sent, send it now + if (p._pendingToolCallMessage) { + debugLog( + `[TaskSync] handleWebviewReady — sending deferred toolCallPending message, id: ${p._pendingToolCallMessage.id}`, + ); + const prompt = p._pendingToolCallMessage.prompt; + const choices = parseChoices(prompt); + const isApproval = choices.length === 0 && isApprovalQuestion(prompt); + p._view?.webview.postMessage({ + type: "toolCallPending", + id: p._pendingToolCallMessage.id, + prompt: prompt, + isApproval, + choices: choices.length > 0 ? choices : undefined, + summary: p._pendingToolCallMessage.summary, + } satisfies ToWebviewMessage); + p._pendingToolCallMessage = null; + } + // If there's an active pending request (webview was hidden/recreated while waiting), + // re-send the pending tool call message so the user sees the question again + else if ( + p._currentToolCallId && + p._pendingRequests.has(p._currentToolCallId) + ) { + debugLog( + `[TaskSync] handleWebviewReady — re-sending pending tool call, id: ${p._currentToolCallId}`, + ); + // Find the pending entry to get the prompt + const pendingEntry = p._currentSessionCallsMap.get(p._currentToolCallId); + if (pendingEntry && pendingEntry.status === "pending") { + const prompt = pendingEntry.prompt; + const choices = parseChoices(prompt); + const isApproval = choices.length === 0 && isApprovalQuestion(prompt); + p._view?.webview.postMessage({ + type: "toolCallPending", + id: p._currentToolCallId, + prompt: prompt, + isApproval, + choices: choices.length > 0 ? choices : undefined, + summary: pendingEntry.summary, + } satisfies ToWebviewMessage); + } + } +} + +/** + * Handle submit from webview. + */ +export function handleSubmit( + p: P, + value: string, + attachments: AttachmentInfo[], +): void { + // Cancel response timeout timer (user responded) + if (p._responseTimeoutTimer) { + debugLog("[TaskSync] handleSubmit — cancelling response timeout timer"); + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + // Reset consecutive auto-responses counter on manual response + p._consecutiveAutoResponses = 0; + + if (p._pendingRequests.size > 0 && p._currentToolCallId) { + const resolve = p._pendingRequests.get(p._currentToolCallId); + if (resolve) { + debugLog( + "[TaskSync] handleSubmit — resolving toolCallId:", + p._currentToolCallId, + "response:", + value.slice(0, 80), + ); + // O(1) lookup using Map instead of O(n) findIndex + const pendingEntry = p._currentSessionCallsMap.get(p._currentToolCallId); + + let completedEntry: ToolCallEntry; + if (pendingEntry && pendingEntry.status === "pending") { + // Update existing pending entry + pendingEntry.response = value; + pendingEntry.attachments = attachments; + pendingEntry.status = "completed"; + pendingEntry.timestamp = Date.now(); + completedEntry = pendingEntry; + } else { + // Create new completed entry (shouldn't happen normally) + completedEntry = { + id: p._currentToolCallId, + prompt: "Tool call", + response: value, + attachments: attachments, + timestamp: Date.now(), + isFromQueue: false, + status: "completed", + }; + p._currentSessionCalls.unshift(completedEntry); + p._currentSessionCallsMap.set(completedEntry.id, completedEntry); + } + + // Detect session termination + const isTermination = value === p._SESSION_TERMINATION_TEXT; + + // Send toolCallCompleted to trigger "Working...." state in webview + p._view?.webview.postMessage({ + type: "toolCallCompleted", + entry: completedEntry, + sessionTerminated: isTermination, + } satisfies ToWebviewMessage); + + // Broadcast to remote clients so they see the answer + broadcastToolCallCompleted(p, completedEntry, isTermination); + + p._updateCurrentSessionUI(); + resolve({ + value, + queue: hasQueuedItems(p), + attachments, + }); + p._pendingRequests.delete(p._currentToolCallId); + p._currentToolCallId = null; + p._aiTurnActive = true; // AI is now processing the response + debugLog( + `[TaskSync] handleSubmit — resolved, aiTurnActive: true, isTermination: ${isTermination}`, + ); + + // Mark session as terminated if termination text was submitted + if (isTermination) { + debugLog("[TaskSync] handleSubmit — marking session terminated"); + markSessionTerminated(p); + } + } else { + debugLog( + `[TaskSync] handleSubmit — no resolve found for toolCallId: ${p._currentToolCallId}, adding to queue`, + ); + // No pending tool call - add message to queue for later use + if (value && value.trim()) { + const queuedPrompt: QueuedPrompt = { + id: generateId("q"), + prompt: value.trim(), + }; + p._promptQueue.push(queuedPrompt); + // Auto-switch to queue mode so user sees their message went to queue + p._queueEnabled = true; + notifyQueueChanged(p); + } + } + // NOTE: Temp images are NOT cleaned up here anymore. + // They are stored in the ToolCallEntry.attachments and will be cleaned up when: + // 1. clearCurrentSession() is called + // 2. dispose() is called (extension deactivation) + + // Clear attachments after submit and sync with webview + p._attachments = []; + p._updateAttachmentsUI(); + } +} diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts new file mode 100644 index 0000000..c16ace8 --- /dev/null +++ b/tasksync-chat/src/webview/persistence.ts @@ -0,0 +1,210 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { P, ToolCallEntry } from "./webviewTypes"; +import { mergeAndDedup } from "./webviewUtils"; + +/** + * Get workspace-aware storage URI. + */ +export function getStorageUri(p: P): vscode.Uri { + return p._context.storageUri || p._context.globalStorageUri; +} + +/** + * Load queue from disk (async). + */ +export async function loadQueueFromDiskAsync(p: P): Promise { + try { + const storagePath = getStorageUri(p).fsPath; + const queuePath = path.join(storagePath, "queue.json"); + + try { + await fs.promises.access(queuePath, fs.constants.F_OK); + } catch { + p._promptQueue = []; + p._queueEnabled = true; + return; + } + + const data = await fs.promises.readFile(queuePath, "utf8"); + const parsed = JSON.parse(data); + p._promptQueue = Array.isArray(parsed.queue) ? parsed.queue : []; + p._queueEnabled = parsed.enabled === true; + } catch (error) { + console.error("[TaskSync] Failed to load queue:", error); + p._promptQueue = []; + p._queueEnabled = true; + } +} + +/** + * Save queue to disk (debounced). + */ +export function saveQueueToDisk(p: P): void { + if (p._queueSaveTimer) { + clearTimeout(p._queueSaveTimer); + } + p._queueSaveTimer = setTimeout(() => { + saveQueueToDiskAsync(p); + }, p._QUEUE_SAVE_DEBOUNCE_MS); +} + +/** + * Actually persist queue to disk. + */ +export async function saveQueueToDiskAsync(p: P): Promise { + try { + const storagePath = getStorageUri(p).fsPath; + const queuePath = path.join(storagePath, "queue.json"); + + if (!fs.existsSync(storagePath)) { + await fs.promises.mkdir(storagePath, { recursive: true }); + } + + const data = JSON.stringify( + { + queue: p._promptQueue, + enabled: p._queueEnabled, + }, + null, + 2, + ); + + await fs.promises.writeFile(queuePath, data, "utf8"); + } catch (error) { + console.error("[TaskSync] Failed to save queue:", error); + } +} + +/** + * Load persisted history from disk (async). + */ +export async function loadPersistedHistoryFromDiskAsync(p: P): Promise { + try { + const storagePath = getStorageUri(p).fsPath; + const historyPath = path.join(storagePath, "tool-history.json"); + + try { + await fs.promises.access(historyPath, fs.constants.F_OK); + } catch { + p._persistedHistory = []; + return; + } + + const data = await fs.promises.readFile(historyPath, "utf8"); + const parsed = JSON.parse(data); + p._persistedHistory = Array.isArray(parsed.history) + ? parsed.history + .filter((entry: ToolCallEntry) => entry.status === "completed") + .slice(0, p._MAX_HISTORY_ENTRIES) + : []; + } catch (error) { + console.error("[TaskSync] Failed to load persisted history:", error); + p._persistedHistory = []; + } +} + +/** + * Save persisted history to disk (debounced async). + */ +export function savePersistedHistoryToDisk(p: P): void { + p._historyDirty = true; + + if (p._historySaveTimer) { + clearTimeout(p._historySaveTimer); + } + + p._historySaveTimer = setTimeout(() => { + savePersistedHistoryToDiskAsync(p); + }, p._HISTORY_SAVE_DEBOUNCE_MS); +} + +/** + * Async save persisted history (non-blocking background save). + */ +export async function savePersistedHistoryToDiskAsync(p: P): Promise { + try { + const storagePath = getStorageUri(p).fsPath; + const historyPath = path.join(storagePath, "tool-history.json"); + + try { + await fs.promises.access(storagePath); + } catch { + await fs.promises.mkdir(storagePath, { recursive: true }); + } + + const completedHistory = p._persistedHistory.filter( + (entry: ToolCallEntry) => entry.status === "completed", + ); + + let merged = completedHistory; + try { + const existing = await fs.promises.readFile(historyPath, "utf8"); + const parsed = JSON.parse(existing); + if (Array.isArray(parsed.history)) { + merged = mergeAndDedup( + completedHistory, + parsed.history, + p._MAX_HISTORY_ENTRIES, + ); + } + } catch { + // File doesn't exist or is invalid + } + + p._persistedHistory = merged; + + const data = JSON.stringify({ history: merged }, null, 2); + await fs.promises.writeFile(historyPath, data, "utf8"); + p._historyDirty = false; + } catch (error) { + console.error( + "[TaskSync] Failed to save persisted history (async):", + error, + ); + } +} + +/** + * Synchronous save persisted history (only for deactivate). + */ +export function savePersistedHistoryToDiskSync(p: P): void { + if (!p._historyDirty) return; + + try { + const storagePath = getStorageUri(p).fsPath; + const historyPath = path.join(storagePath, "tool-history.json"); + + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath, { recursive: true }); + } + + const completedHistory = p._persistedHistory.filter( + (entry: ToolCallEntry) => entry.status === "completed", + ); + + let merged = completedHistory; + try { + const existing = fs.readFileSync(historyPath, "utf8"); + const parsed = JSON.parse(existing); + if (Array.isArray(parsed.history)) { + merged = mergeAndDedup( + completedHistory, + parsed.history, + p._MAX_HISTORY_ENTRIES, + ); + } + } catch { + // File doesn't exist or is invalid + } + + p._persistedHistory = merged; + + const data = JSON.stringify({ history: merged }, null, 2); + fs.writeFileSync(historyPath, data, "utf8"); + p._historyDirty = false; + } catch (error) { + console.error("[TaskSync] Failed to save persisted history:", error); + } +} diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts new file mode 100644 index 0000000..335805a --- /dev/null +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { + handleAddQueuePrompt, + handleClearPersistedHistory, + handleRemoveHistoryItem, + handleToggleQueue, +} from "../webview/queueHandlers"; + +// ─── Mock P factory ───────────────────────────────────────── + +function createMockP(overrides: Partial = {}) { + return { + _promptQueue: [] as any[], + _queueEnabled: true, + _queueVersion: 0, + _currentToolCallId: null as string | null, + _pendingRequests: new Map void>(), + _currentSessionCalls: [] as any[], + _currentSessionCallsMap: new Map(), + _attachments: [] as any[], + _responseTimeoutTimer: null as any, + _view: { + webview: { + postMessage: vi.fn(), + }, + }, + _remoteServer: null as any, + _persistedHistory: [] as any[], + _saveQueueToDisk: vi.fn(), + _updateQueueUI: vi.fn(), + _updateCurrentSessionUI: vi.fn(), + _updateAttachmentsUI: vi.fn(), + _updatePersistedHistoryUI: vi.fn(), + _savePersistedHistoryToDisk: vi.fn(), + ...overrides, + } as any; +} + +// ─── handleAddQueuePrompt ─────────────────────────────────── + +describe("handleAddQueuePrompt", () => { + it("adds prompt to queue when no pending tool call", () => { + const p = createMockP(); + handleAddQueuePrompt(p, "Do this task", "q_1_abc", []); + expect(p._promptQueue).toHaveLength(1); + expect(p._promptQueue[0].prompt).toBe("Do this task"); + expect(p._promptQueue[0].id).toBe("q_1_abc"); + }); + + it("trims whitespace from prompt", () => { + const p = createMockP(); + handleAddQueuePrompt(p, " trimmed ", "q_1_abc", []); + expect(p._promptQueue[0].prompt).toBe("trimmed"); + }); + + it("generates ID when none provided", () => { + const p = createMockP(); + handleAddQueuePrompt(p, "task", "", []); + expect(p._promptQueue[0].id).toMatch(/^q_/); + }); + + it("rejects empty prompt", () => { + const p = createMockP(); + handleAddQueuePrompt(p, "", "q_1_abc", []); + expect(p._promptQueue).toHaveLength(0); + }); + + it("rejects whitespace-only prompt", () => { + const p = createMockP(); + handleAddQueuePrompt(p, " ", "q_1_abc", []); + expect(p._promptQueue).toHaveLength(0); + }); + + it("rejects overly long prompt", () => { + const p = createMockP(); + const longPrompt = "x".repeat(100001); + handleAddQueuePrompt(p, longPrompt, "q_1_abc", []); + expect(p._promptQueue).toHaveLength(0); + }); + + it("includes attachments when provided", () => { + const p = createMockP(); + const attachments = [{ id: "a1", name: "file.ts", uri: "/file.ts" }]; + handleAddQueuePrompt(p, "task", "q_1_abc", attachments); + expect(p._promptQueue[0].attachments).toEqual(attachments); + }); + + it("omits attachments field when empty", () => { + const p = createMockP(); + handleAddQueuePrompt(p, "task", "q_1_abc", []); + expect(p._promptQueue[0].attachments).toBeUndefined(); + }); + + it("clears attachments after adding to queue", () => { + const p = createMockP({ _attachments: [{ id: "a1" }] }); + handleAddQueuePrompt(p, "task", "q_1_abc", []); + expect(p._attachments).toEqual([]); + expect(p._updateAttachmentsUI).toHaveBeenCalled(); + }); + + // Auto-respond path + it("auto-responds when queue is enabled and tool call is pending", () => { + const resolve = vi.fn(); + const pendingRequests = new Map(); + pendingRequests.set("tc_1", resolve); + + const pendingEntry = { + id: "tc_1", + prompt: "Question?", + response: "", + status: "pending", + timestamp: 0, + isFromQueue: false, + }; + const sessionCallsMap = new Map(); + sessionCallsMap.set("tc_1", pendingEntry); + + const p = createMockP({ + _queueEnabled: true, + _currentToolCallId: "tc_1", + _pendingRequests: pendingRequests, + _currentSessionCalls: [pendingEntry], + _currentSessionCallsMap: sessionCallsMap, + }); + + handleAddQueuePrompt(p, "Auto answer", "q_1_abc", []); + + // Should have resolved the pending request + expect(resolve).toHaveBeenCalledWith( + expect.objectContaining({ value: "Auto answer" }), + ); + // Should NOT have added to queue + expect(p._promptQueue).toHaveLength(0); + // Should have cleared tool call + expect(p._currentToolCallId).toBeNull(); + // Should have updated the pending entry + expect(pendingEntry.status).toBe("completed"); + expect(pendingEntry.response).toBe("Auto answer"); + expect(pendingEntry.isFromQueue).toBe(true); + }); + + it("creates new entry when pending entry not found in session map", () => { + const resolve = vi.fn(); + const pendingRequests = new Map(); + pendingRequests.set("tc_1", resolve); + + const p = createMockP({ + _queueEnabled: true, + _currentToolCallId: "tc_1", + _pendingRequests: pendingRequests, + _currentSessionCalls: [], + _currentSessionCallsMap: new Map(), + }); + + handleAddQueuePrompt(p, "Answer", "q_1_abc", []); + + expect(resolve).toHaveBeenCalled(); + expect(p._currentSessionCalls).toHaveLength(1); + expect(p._currentSessionCalls[0].status).toBe("completed"); + }); + + it("clears response timeout timer on auto-respond", () => { + const resolve = vi.fn(); + const pendingRequests = new Map(); + pendingRequests.set("tc_1", resolve); + const timer = setTimeout(() => {}, 10000); + + const p = createMockP({ + _queueEnabled: true, + _currentToolCallId: "tc_1", + _pendingRequests: pendingRequests, + _currentSessionCalls: [], + _currentSessionCallsMap: new Map(), + _responseTimeoutTimer: timer, + }); + + handleAddQueuePrompt(p, "Answer", "q_1_abc", []); + + expect(p._responseTimeoutTimer).toBeNull(); + clearTimeout(timer); + }); + + it("does not auto-respond when queue is disabled", () => { + const resolve = vi.fn(); + const pendingRequests = new Map(); + pendingRequests.set("tc_1", resolve); + + const p = createMockP({ + _queueEnabled: false, + _currentToolCallId: "tc_1", + _pendingRequests: pendingRequests, + }); + + handleAddQueuePrompt(p, "Task", "q_1_abc", []); + + expect(resolve).not.toHaveBeenCalled(); + expect(p._promptQueue).toHaveLength(1); + }); + + it("does not auto-respond when no current tool call", () => { + const p = createMockP({ _queueEnabled: true, _currentToolCallId: null }); + handleAddQueuePrompt(p, "Task", "q_1_abc", []); + expect(p._promptQueue).toHaveLength(1); + }); + + it("handles case where resolve is missing from pendingRequests", () => { + const pendingRequests = new Map(); + pendingRequests.set("tc_1", undefined); + + const p = createMockP({ + _queueEnabled: true, + _currentToolCallId: "tc_1", + _pendingRequests: pendingRequests, + }); + + handleAddQueuePrompt(p, "Task", "q_1_abc", []); + // Should not add to queue (shouldAutoRespond was true, but resolve was falsy) + expect(p._promptQueue).toHaveLength(0); + }); +}); + +// ─── handleToggleQueue ────────────────────────────────────── + +describe("handleToggleQueue", () => { + it("enables queue and saves", () => { + const p = createMockP({ _queueEnabled: false }); + handleToggleQueue(p, true); + expect(p._queueEnabled).toBe(true); + expect(p._saveQueueToDisk).toHaveBeenCalled(); + expect(p._updateQueueUI).toHaveBeenCalled(); + }); + + it("disables queue and saves", () => { + const p = createMockP({ _queueEnabled: true }); + handleToggleQueue(p, false); + expect(p._queueEnabled).toBe(false); + }); + + it("broadcasts to remote server when available", () => { + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + inspect: vi.fn().mockReturnValue(undefined), + } as any); + const broadcast = vi.fn(); + const p = createMockP({ _remoteServer: { broadcast } }); + handleToggleQueue(p, true); + expect(broadcast).toHaveBeenCalledWith( + "settingsChanged", + expect.objectContaining({ queueEnabled: true }), + ); + }); +}); + +// ─── handleRemoveHistoryItem ──────────────────────────────── + +describe("handleRemoveHistoryItem", () => { + it("removes history item by call ID", () => { + const p = createMockP({ + _persistedHistory: [ + { id: "tc_1", prompt: "Q1", response: "A1" }, + { id: "tc_2", prompt: "Q2", response: "A2" }, + { id: "tc_3", prompt: "Q3", response: "A3" }, + ], + }); + handleRemoveHistoryItem(p, "tc_2"); + expect(p._persistedHistory).toHaveLength(2); + expect(p._persistedHistory.map((h: any) => h.id)).toEqual(["tc_1", "tc_3"]); + expect(p._updatePersistedHistoryUI).toHaveBeenCalled(); + expect(p._savePersistedHistoryToDisk).toHaveBeenCalled(); + }); + + it("does nothing for non-existent ID", () => { + const p = createMockP({ + _persistedHistory: [{ id: "tc_1", prompt: "Q", response: "A" }], + }); + handleRemoveHistoryItem(p, "tc_999"); + expect(p._persistedHistory).toHaveLength(1); + }); +}); + +// ─── handleClearPersistedHistory ──────────────────────────── + +describe("handleClearPersistedHistory", () => { + it("clears all persisted history", () => { + const p = createMockP({ + _persistedHistory: [{ id: "tc_1" }, { id: "tc_2" }, { id: "tc_3" }], + }); + handleClearPersistedHistory(p); + expect(p._persistedHistory).toHaveLength(0); + expect(p._updatePersistedHistoryUI).toHaveBeenCalled(); + expect(p._savePersistedHistoryToDisk).toHaveBeenCalled(); + }); + + it("handles empty history gracefully", () => { + const p = createMockP({ _persistedHistory: [] }); + handleClearPersistedHistory(p); + expect(p._persistedHistory).toHaveLength(0); + }); +}); diff --git a/tasksync-chat/src/webview/queueHandlers.test.ts b/tasksync-chat/src/webview/queueHandlers.test.ts new file mode 100644 index 0000000..6c682f1 --- /dev/null +++ b/tasksync-chat/src/webview/queueHandlers.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; +import { + handleClearQueue, + handleEditQueuePrompt, + handleRemoveQueuePrompt, + handleReorderQueue, +} from "../webview/queueHandlers"; + +// Valid queue IDs must match /^q_\d+_[a-z0-9]+$/ +const ID1 = "q_100_abc"; +const ID2 = "q_200_def"; +const ID3 = "q_300_ghi"; + +function createMockP(queue: Array<{ id: string; prompt: string }> = []) { + return { + _promptQueue: queue.map((q) => ({ ...q })), + _queueEnabled: true, + _queueVersion: 0, + _saveQueueToDisk: vi.fn(), + _updateQueueUI: vi.fn(), + _remoteServer: null, + } as any; +} + +// ─── handleRemoveQueuePrompt ───────────────────────────────── + +describe("handleRemoveQueuePrompt", () => { + it("removes the matching prompt from queue", () => { + const p = createMockP([ + { id: ID1, prompt: "First" }, + { id: ID2, prompt: "Second" }, + { id: ID3, prompt: "Third" }, + ]); + handleRemoveQueuePrompt(p, ID2); + expect(p._promptQueue).toHaveLength(2); + expect(p._promptQueue.map((q: any) => q.id)).toEqual([ID1, ID3]); + }); + + it("does nothing for non-existent ID", () => { + const p = createMockP([{ id: ID1, prompt: "First" }]); + handleRemoveQueuePrompt(p, "q_999_zzz"); + expect(p._promptQueue).toHaveLength(1); + }); + + it("rejects invalid queue IDs", () => { + const p = createMockP([{ id: ID1, prompt: "First" }]); + handleRemoveQueuePrompt(p, ""); + expect(p._promptQueue).toHaveLength(1); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); + }); +}); + +// ─── handleEditQueuePrompt ─────────────────────────────────── + +describe("handleEditQueuePrompt", () => { + it("updates the prompt text for matching ID", () => { + const p = createMockP([{ id: ID1, prompt: "Old text" }]); + handleEditQueuePrompt(p, ID1, "New text"); + expect(p._promptQueue[0].prompt).toBe("New text"); + }); + + it("trims whitespace from new prompt", () => { + const p = createMockP([{ id: ID1, prompt: "Old" }]); + handleEditQueuePrompt(p, ID1, " Trimmed "); + expect(p._promptQueue[0].prompt).toBe("Trimmed"); + }); + + it("rejects empty/whitespace-only new prompt", () => { + const p = createMockP([{ id: ID1, prompt: "Keep this" }]); + handleEditQueuePrompt(p, ID1, " "); + expect(p._promptQueue[0].prompt).toBe("Keep this"); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); + }); + + it("rejects excessively long prompts", () => { + const p = createMockP([{ id: ID1, prompt: "Keep" }]); + const longPrompt = "x".repeat(100001); + }); + + it("does nothing for non-existent ID", () => { + const p = createMockP([{ id: ID1, prompt: "Keep" }]); + handleEditQueuePrompt(p, "q_999_zzz", "New text"); + expect(p._promptQueue[0].prompt).toBe("Keep"); + }); + + it("rejects invalid queue IDs", () => { + const p = createMockP([{ id: ID1, prompt: "Keep" }]); + handleEditQueuePrompt(p, "", "New text"); + expect(p._promptQueue[0].prompt).toBe("Keep"); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); + }); +}); + +// ─── handleReorderQueue ────────────────────────────────────── + +describe("handleReorderQueue", () => { + it("moves item forward in queue", () => { + const p = createMockP([ + { id: ID1, prompt: "A" }, + { id: ID2, prompt: "B" }, + { id: ID3, prompt: "C" }, + ]); + handleReorderQueue(p, 0, 2); + expect(p._promptQueue.map((q: any) => q.id)).toEqual([ID2, ID3, ID1]); + }); + + it("moves item backward in queue", () => { + const p = createMockP([ + { id: ID1, prompt: "A" }, + { id: ID2, prompt: "B" }, + { id: ID3, prompt: "C" }, + ]); + handleReorderQueue(p, 2, 0); + expect(p._promptQueue.map((q: any) => q.id)).toEqual([ID3, ID1, ID2]); + }); + + it("no-op when from and to are the same", () => { + const p = createMockP([ + { id: ID1, prompt: "A" }, + { id: ID2, prompt: "B" }, + ]); + handleReorderQueue(p, 0, 0); + expect(p._promptQueue.map((q: any) => q.id)).toEqual([ID1, ID2]); + }); + + it("rejects negative indices", () => { + const p = createMockP([{ id: ID1, prompt: "A" }]); + handleReorderQueue(p, -1, 0); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); + }); + + it("rejects out-of-bounds indices", () => { + const p = createMockP([{ id: ID1, prompt: "A" }]); + handleReorderQueue(p, 0, 5); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); + }); + + it("rejects non-integer indices", () => { + const p = createMockP([ + { id: ID1, prompt: "A" }, + { id: ID2, prompt: "B" }, + ]); + handleReorderQueue(p, 0.5, 1); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); + }); +}); + +// ─── handleClearQueue ──────────────────────────────────────── + +describe("handleClearQueue", () => { + it("empties the queue", () => { + const p = createMockP([ + { id: ID1, prompt: "A" }, + { id: ID2, prompt: "B" }, + ]); + handleClearQueue(p); + expect(p._promptQueue).toHaveLength(0); + expect(p._saveQueueToDisk).toHaveBeenCalled(); + }); + + it("no-op on empty queue", () => { + const p = createMockP([]); + handleClearQueue(p); + expect(p._promptQueue).toHaveLength(0); + }); +}); diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts new file mode 100644 index 0000000..28dbe04 --- /dev/null +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -0,0 +1,221 @@ +import { + isValidQueueId, + MAX_QUEUE_PROMPT_LENGTH, +} from "../constants/remoteConstants"; +import { buildSettingsPayload } from "./settingsHandlers"; +import type { + AttachmentInfo, + P, + QueuedPrompt, + ToolCallEntry, + ToWebviewMessage, +} from "./webviewTypes"; +import { + broadcastToolCallCompleted, + debugLog, + generateId, + hasQueuedItems, + notifyQueueChanged, +} from "./webviewUtils"; + +/** + * Handle adding a prompt to queue. + */ +export function handleAddQueuePrompt( + p: P, + prompt: string, + id: string, + attachments: AttachmentInfo[], +): void { + const trimmed = prompt.trim(); + if (!trimmed || trimmed.length > MAX_QUEUE_PROMPT_LENGTH) { + debugLog( + `[TaskSync] handleAddQueuePrompt — rejected: empty or exceeds max length (${trimmed.length}/${MAX_QUEUE_PROMPT_LENGTH})`, + ); + return; + } + + debugLog( + `[TaskSync] handleAddQueuePrompt — prompt: "${trimmed.slice(0, 60)}", attachments: ${attachments.length}, currentToolCallId: ${p._currentToolCallId}`, + ); + + const queuedPrompt: QueuedPrompt = { + id: id || generateId("q"), + prompt: trimmed, + attachments: attachments.length > 0 ? [...attachments] : undefined, + }; + + // Check if we should auto-respond BEFORE adding to queue (race condition fix) + const currentCallId = p._currentToolCallId; + const shouldAutoRespond = + p._queueEnabled && currentCallId && p._pendingRequests.has(currentCallId); + + if (shouldAutoRespond) { + debugLog( + `[TaskSync] handleAddQueuePrompt — auto-responding to pending tool call: ${currentCallId}`, + ); + const resolve = p._pendingRequests.get(currentCallId); + if (!resolve) return; + + const pendingEntry = p._currentSessionCallsMap.get(currentCallId); + + let completedEntry: ToolCallEntry; + if (pendingEntry && pendingEntry.status === "pending") { + pendingEntry.response = queuedPrompt.prompt; + pendingEntry.attachments = queuedPrompt.attachments; + pendingEntry.status = "completed"; + pendingEntry.isFromQueue = true; + pendingEntry.timestamp = Date.now(); + completedEntry = pendingEntry; + } else { + completedEntry = { + id: currentCallId, + prompt: "Tool call", + response: queuedPrompt.prompt, + attachments: queuedPrompt.attachments, + timestamp: Date.now(), + isFromQueue: true, + status: "completed", + }; + p._currentSessionCalls.unshift(completedEntry); + p._currentSessionCallsMap.set(completedEntry.id, completedEntry); + } + + p._view?.webview.postMessage({ + type: "toolCallCompleted", + entry: completedEntry, + } satisfies ToWebviewMessage); + + p._updateCurrentSessionUI(); + p._saveQueueToDisk(); + p._updateQueueUI(); + + broadcastToolCallCompleted(p, completedEntry); + + // Clear response timeout timer (matches resolveRemoteResponse behavior) + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + resolve({ + value: queuedPrompt.prompt, + queue: hasQueuedItems(p), + attachments: queuedPrompt.attachments || [], + }); + p._pendingRequests.delete(currentCallId); + p._currentToolCallId = null; + } else { + debugLog( + `[TaskSync] handleAddQueuePrompt — no pending tool call, adding to queue (new size: ${p._promptQueue.length + 1})`, + ); + p._promptQueue.push(queuedPrompt); + notifyQueueChanged(p); + } + + // Clear attachments after adding to queue + p._attachments = []; + p._updateAttachmentsUI(); +} + +/** + * Handle removing a prompt from queue. + */ +export function handleRemoveQueuePrompt(p: P, promptId: string): void { + if (!isValidQueueId(promptId)) return; + debugLog( + `[TaskSync] handleRemoveQueuePrompt — promptId: ${promptId}, queueSize before: ${p._promptQueue.length}`, + ); + p._promptQueue = p._promptQueue.filter( + (pr: QueuedPrompt) => pr.id !== promptId, + ); + notifyQueueChanged(p); +} + +/** + * Handle editing a prompt in queue. + */ +export function handleEditQueuePrompt( + p: P, + promptId: string, + newPrompt: string, +): void { + if (!isValidQueueId(promptId)) return; + const trimmed = newPrompt.trim(); + if (!trimmed || trimmed.length > MAX_QUEUE_PROMPT_LENGTH) return; + + debugLog( + `[TaskSync] handleEditQueuePrompt — promptId: ${promptId}, newPrompt: "${trimmed.slice(0, 60)}"`, + ); + const prompt = p._promptQueue.find((pr: QueuedPrompt) => pr.id === promptId); + if (prompt) { + prompt.prompt = trimmed; + notifyQueueChanged(p); + } +} + +/** + * Handle reordering queue. + */ +export function handleReorderQueue( + p: P, + fromIndex: number, + toIndex: number, +): void { + if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) return; + if (fromIndex < 0 || toIndex < 0) return; + if (fromIndex >= p._promptQueue.length || toIndex >= p._promptQueue.length) + return; + + const [removed] = p._promptQueue.splice(fromIndex, 1); + p._promptQueue.splice(toIndex, 0, removed); + notifyQueueChanged(p); +} + +/** + * Handle toggling queue enabled state. + */ +export function handleToggleQueue(p: P, enabled: boolean): void { + debugLog( + `[TaskSync] handleToggleQueue — enabled: ${enabled}, queueSize: ${p._promptQueue.length}`, + ); + p._queueEnabled = enabled; + p._saveQueueToDisk(); + p._updateQueueUI(); + p._remoteServer?.broadcast("settingsChanged", buildSettingsPayload(p)); +} + +/** + * Handle clearing the queue. + */ +export function handleClearQueue(p: P): void { + debugLog( + `[TaskSync] handleClearQueue — clearing ${p._promptQueue.length} items`, + ); + p._promptQueue = []; + notifyQueueChanged(p); +} + +/** + * Handle removing a history item from persisted history (modal only). + */ +export function handleRemoveHistoryItem(p: P, callId: string): void { + debugLog(`[TaskSync] handleRemoveHistoryItem — callId: ${callId}`); + p._persistedHistory = p._persistedHistory.filter( + (tc: ToolCallEntry) => tc.id !== callId, + ); + p._updatePersistedHistoryUI(); + p._savePersistedHistoryToDisk(); +} + +/** + * Handle clearing all persisted history. + */ +export function handleClearPersistedHistory(p: P): void { + debugLog( + `[TaskSync] handleClearPersistedHistory — clearing ${p._persistedHistory.length} items`, + ); + p._persistedHistory = []; + p._updatePersistedHistoryUI(); + p._savePersistedHistoryToDisk(); +} diff --git a/tasksync-chat/src/webview/remoteApiHandlers.ts b/tasksync-chat/src/webview/remoteApiHandlers.ts new file mode 100644 index 0000000..03f1fed --- /dev/null +++ b/tasksync-chat/src/webview/remoteApiHandlers.ts @@ -0,0 +1,409 @@ +/** + * Remote API handlers extracted from webviewProvider.ts. + * All public methods that the remote server calls. + */ +import * as path from "path"; +import * as vscode from "vscode"; + +import { + FILE_SEARCH_EXCLUSION_PATTERNS, + formatExcludePattern, +} from "../constants/fileExclusions"; +import { + ErrorCode, + MAX_QUEUE_PROMPT_LENGTH, + MAX_QUEUE_SIZE, + MAX_REMOTE_HISTORY_ITEMS, +} from "../constants/remoteConstants"; +import { isApprovalQuestion, parseChoices } from "./choiceParser"; +import { searchToolsForAutocomplete } from "./fileHandlers"; +import { + handleClearQueue, + handleEditQueuePrompt, + handleRemoveQueuePrompt, + handleReorderQueue, + handleToggleQueue, +} from "./queueHandlers"; +import * as settingsH from "./settingsHandlers"; +import type { + AttachmentInfo, + FileSearchResult, + P, + UserResponseResult, +} from "./webviewTypes"; +import { + broadcastToolCallCompleted, + debugLog, + generateId, + getFileIcon, + hasQueuedItems, + notifyQueueChanged, +} from "./webviewUtils"; + +/** + * Get current state for remote clients. + */ +export function getRemoteState(p: P): { + pending: { + id: string; + prompt: string; + summary?: string; + choices?: Array<{ label: string; value: string; shortLabel?: string }>; + isApproval: boolean; + timestamp: number; + } | null; + queue: Array<{ id: string; prompt: string; attachments: AttachmentInfo[] }>; + queueVersion: number; + history: Array<{ + id: string; + prompt: string; + summary?: string; + response: string; + timestamp: number; + status: "pending" | "completed" | "cancelled"; + isFromQueue: boolean; + attachments: AttachmentInfo[]; + }>; + settings: ReturnType; + session: { startTime: number | null; toolCallCount: number }; + isProcessing: boolean; + model: string; +} { + const pendingEntry = p._currentToolCallId + ? p._currentSessionCallsMap.get(p._currentToolCallId) + : null; + + const result = { + pending: + pendingEntry && pendingEntry.status === "pending" + ? { + id: pendingEntry.id, + prompt: pendingEntry.prompt, + summary: pendingEntry.summary, + choices: parseChoices(pendingEntry.prompt).map((c) => ({ + label: c.label, + value: c.value, + shortLabel: c.shortLabel, + })), + isApproval: isApprovalQuestion(pendingEntry.prompt), + timestamp: pendingEntry.timestamp, + } + : null, + queue: p._promptQueue.map( + (q: { id: string; prompt: string; attachments?: AttachmentInfo[] }) => ({ + id: q.id, + prompt: q.prompt, + attachments: q.attachments || [], + }), + ), + queueVersion: p._queueVersion, + history: p._currentSessionCalls + .slice(0, MAX_REMOTE_HISTORY_ITEMS) + .map( + (c: { + id: string; + prompt: string; + summary?: string; + response: string; + timestamp: number; + status: "pending" | "completed" | "cancelled"; + isFromQueue: boolean; + attachments?: AttachmentInfo[]; + }) => ({ + id: c.id, + prompt: c.prompt, + summary: c.summary, + response: c.response, + timestamp: c.timestamp, + status: c.status, + isFromQueue: c.isFromQueue, + attachments: c.attachments || [], + }), + ), + settings: settingsH.buildSettingsPayload(p), + session: { + startTime: p._sessionStartTime, + toolCallCount: p._currentSessionCalls.length, + }, + // True when AI is actively working (between user response and next askUser call) + isProcessing: p._aiTurnActive, + model: p._lastKnownModel, + }; + + debugLog( + "[TaskSync] getRemoteState — aiTurnActive:", + p._aiTurnActive, + "currentToolCallId:", + p._currentToolCallId, + "pendingRequests:", + p._pendingRequests.size, + "=> isProcessing:", + result.isProcessing, + "pending:", + !!result.pending, + ); + + return result; +} + +/** + * Resolve a response from a remote client. + * Returns false if the tool call was already answered (no pending resolver found). + */ +export function resolveRemoteResponse( + p: P, + toolCallId: string, + value: string, + attachments: AttachmentInfo[], +): boolean { + const resolver = p._pendingRequests.get(toolCallId); + if (!resolver) { + debugLog( + "[TaskSync] resolveRemoteResponse — no pending resolver for:", + toolCallId, + ); + return false; + } + debugLog( + "[TaskSync] resolveRemoteResponse — resolving toolCallId:", + toolCallId, + "response:", + value.slice(0, 80), + "attachments:", + attachments.length, + ); + p._pendingRequests.delete(toolCallId); + + // Reset consecutive auto-responses counter — remote manual response is human interaction + p._consecutiveAutoResponses = 0; + + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + resolver({ + value, + attachments, + queue: hasQueuedItems(p), + } as UserResponseResult); + + const entry = p._currentSessionCallsMap.get(toolCallId); + if (entry) { + entry.response = value; + entry.status = "completed"; + entry.attachments = attachments; + entry.timestamp = Date.now(); + } + + p._currentToolCallId = null; + p._aiTurnActive = true; // AI is now processing the response + debugLog(`[TaskSync] resolveRemoteResponse — resolved, aiTurnActive: true`); + p._updateCurrentSessionUI(); + + if (entry) { + broadcastToolCallCompleted(p, entry); + } + return true; +} + +/** + * Cancel currently pending tool call (if any) and resolve waiting promise. + */ +export function cancelPendingToolCall( + p: P, + reason = "[Cancelled by user]", +): boolean { + const toolCallId = p._currentToolCallId; + if (!toolCallId) return false; + + const resolver = p._pendingRequests.get(toolCallId); + if (!resolver) return false; + + debugLog( + "[TaskSync] cancelPendingToolCall — toolCallId:", + toolCallId, + "reason:", + reason, + ); + p._pendingRequests.delete(toolCallId); + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + const entry = p._currentSessionCallsMap.get(toolCallId); + if (entry) { + entry.response = reason; + entry.status = "cancelled"; + entry.timestamp = Date.now(); + entry.attachments = []; + } + + resolver({ + value: reason, + attachments: [], + queue: hasQueuedItems(p), + cancelled: true, + } as UserResponseResult); + + p._currentToolCallId = null; + p._aiTurnActive = true; // AI will process the cancellation + debugLog(`[TaskSync] cancelPendingToolCall — cancelled, aiTurnActive: true`); + p._updateCurrentSessionUI(); + + if (entry) { + broadcastToolCallCompleted(p, entry); + } + return true; +} + +/** + * Add to queue from a remote client. + */ +export function addToQueueFromRemote( + p: P, + prompt: string, + attachments: AttachmentInfo[], +): { error?: string; code?: string } { + const trimmed = (prompt || "").trim(); + debugLog( + `[TaskSync] addToQueueFromRemote — prompt: "${trimmed.slice(0, 60)}", attachments: ${attachments.length}, queueSize: ${p._promptQueue.length}`, + ); + if (!trimmed || trimmed.length > MAX_QUEUE_PROMPT_LENGTH) { + return { error: "Invalid prompt length", code: ErrorCode.INVALID_INPUT }; + } + + if (p._promptQueue.length >= MAX_QUEUE_SIZE) { + return { error: "Queue is full", code: ErrorCode.QUEUE_FULL }; + } + + const id = generateId("q"); + p._promptQueue.push({ id, prompt: trimmed, attachments }); + notifyQueueChanged(p); + return {}; +} + +/** + * Remove from queue by ID. + */ +export function removeFromQueueById(p: P, id: string): void { + handleRemoveQueuePrompt(p, id); +} + +/** + * Edit a queue prompt from remote client. + */ +export function editQueuePromptFromRemote( + p: P, + promptId: string, + newPrompt: string, +): { error?: string; code?: string } { + const prompt = p._promptQueue.find( + (item: { id: string }) => item.id === promptId, + ); + if (!prompt) { + return { + error: "This queue item no longer exists.", + code: ErrorCode.ITEM_NOT_FOUND, + }; + } + handleEditQueuePrompt(p, promptId, newPrompt); + return {}; +} + +/** + * Reorder queue from remote client. + */ +export function reorderQueueFromRemote( + p: P, + fromIndex: number, + toIndex: number, +): void { + handleReorderQueue(p, fromIndex, toIndex); +} + +/** + * Clear queue from remote client. + */ +export function clearQueueFromRemote(p: P): void { + handleClearQueue(p); +} + +/** + * Set autopilot enabled/disabled from remote. + */ +export async function setAutopilotEnabled( + p: P, + enabled: boolean, +): Promise { + await settingsH.handleUpdateAutopilotSetting(p, enabled); + p._updateSettingsUI(); +} + +/** + * Search workspace files and tools for remote client. + * Uses a glob pattern incorporating the query to avoid loading all workspace files. + */ +export async function searchFilesForRemote( + p: P, + query: string, +): Promise { + const queryLower = (query || "").toLowerCase(); + + // Tool results (LM tools matching query — works with empty query too) + const toolResults = searchToolsForAutocomplete(query || ""); + + // File search requires at least 2 chars to avoid loading entire workspace + if (!query || query.length < 2) { + return toolResults; + } + + // Escape glob special characters in the query for safe pattern matching + const safeQuery = query.replace(/[[\]{}()*?!\\]/g, "\\$&"); + const excludePattern = formatExcludePattern(FILE_SEARCH_EXCLUSION_PATTERNS); + // Use glob pattern to narrow results at the VS Code API level + const matchingFiles = await vscode.workspace.findFiles( + `**/*${safeQuery}*`, + excludePattern, + 50, + ); + + const fileResults = matchingFiles + .map((uri: vscode.Uri) => { + const relativePath = vscode.workspace.asRelativePath(uri); + const fileName = path.basename(uri.fsPath); + return { + name: fileName, + path: relativePath, + uri: uri.toString(), + icon: getFileIcon(fileName), + isFolder: false, + }; + }) + .filter( + (file: FileSearchResult) => + file.name.toLowerCase().includes(queryLower) || + file.path.toLowerCase().includes(queryLower), + ); + + return [...toolResults, ...fileResults]; +} + +/** + * Set queue enabled/disabled from remote. + */ +export function setQueueEnabled(p: P, enabled: boolean): void { + handleToggleQueue(p, enabled); +} + +/** + * Set response timeout from remote client. + */ +export async function setResponseTimeoutFromRemote( + p: P, + timeout: number, +): Promise { + await settingsH.handleUpdateResponseTimeout(p, timeout); + // VS Code webview + remote broadcast handled by onDidChangeConfiguration +} diff --git a/tasksync-chat/src/webview/sessionManager.ts b/tasksync-chat/src/webview/sessionManager.ts new file mode 100644 index 0000000..9707f46 --- /dev/null +++ b/tasksync-chat/src/webview/sessionManager.ts @@ -0,0 +1,145 @@ +/** + * Session timer, sound, and human-like delay logic extracted from webviewProvider.ts. + */ +import { execFile, spawn } from "child_process"; +import * as vscode from "vscode"; + +import type { P, ToWebviewMessage } from "./webviewTypes"; +import { debugLog, formatElapsed, getHumanLikeDelayMs } from "./webviewUtils"; + +const _TIMER_TOOLTIP = + "It is advisable to start a new session and use another premium request prompt after 2-4h or 50 tool calls"; + +/** + * Apply a random human-like delay before an automated response. + */ +export async function applyHumanLikeDelay(p: P, label?: string): Promise { + const delayMs = getHumanLikeDelayMs( + p._humanLikeDelayEnabled, + p._humanLikeDelayMin, + p._humanLikeDelayMax, + ); + if (delayMs > 0) { + const delaySec = (delayMs / 1000).toFixed(1); + debugLog( + `[TaskSync] applyHumanLikeDelay — ${label || ""} waiting ${delaySec}s`, + ); + if (label) { + vscode.window.setStatusBarMessage( + `TaskSync: ${label} responding in ${delaySec}s...`, + delayMs, + ); + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } +} + +/** + * Update the view title and webview with current session timer state. + */ +export function updateViewTitle(p: P): void { + if (p._view) { + const callCount = p._currentSessionCalls.length; + if (p._sessionFrozenElapsed !== null) { + p._view.title = formatElapsed(p._sessionFrozenElapsed); + p._view.badge = + callCount > 0 + ? { value: callCount, tooltip: _TIMER_TOOLTIP } + : undefined; + } else if (p._sessionStartTime !== null) { + p._view.title = formatElapsed(Date.now() - p._sessionStartTime); + p._view.badge = + callCount > 0 + ? { value: callCount, tooltip: _TIMER_TOOLTIP } + : undefined; + } else { + p._view.title = undefined; + p._view.description = undefined; + p._view.badge = undefined; + } + p._view.webview.postMessage({ + type: "updateSessionTimer", + startTime: p._sessionStartTime, + frozenElapsed: p._sessionFrozenElapsed, + } satisfies ToWebviewMessage); + } +} + +/** + * Start the session timer interval that updates every second. + */ +export function startSessionTimerInterval(p: P): void { + if (p._sessionTimerInterval) return; // Already running + debugLog( + `[TaskSync] startSessionTimerInterval — starting timer, sessionStartTime: ${p._sessionStartTime}`, + ); + p._sessionTimerInterval = setInterval(() => { + if (p._sessionStartTime !== null && p._sessionFrozenElapsed === null) { + const elapsed = Date.now() - p._sessionStartTime; + if (p._view) { + p._view.title = formatElapsed(elapsed); + } + const warningThresholdMs = p._sessionWarningHours * 60 * 60 * 1000; + if ( + p._sessionWarningHours > 0 && + !p._sessionWarningShown && + elapsed >= warningThresholdMs + ) { + p._sessionWarningShown = true; + const callCount = p._currentSessionCalls.length; + const hoursLabel = p._sessionWarningHours === 1 ? "hour" : "hours"; + vscode.window + .showWarningMessage( + `Your session has been running for over ${p._sessionWarningHours} ${hoursLabel} (${callCount} tool calls). Consider starting a new session to maintain quality.`, + "New Session", + "Dismiss", + ) + .then((action: string | undefined) => { + if (action === "New Session") { + p.startNewSession(); + } + }); + } + } + }, 1000); +} + +/** + * Stop the session timer interval. + */ +export function stopSessionTimerInterval(p: P): void { + if (p._sessionTimerInterval) { + debugLog("[TaskSync] stopSessionTimerInterval — stopping timer"); + clearInterval(p._sessionTimerInterval); + p._sessionTimerInterval = null; + } +} + +/** + * Play a system notification sound using OS-native methods. + */ +export function playSystemSound(): void { + const platform = process.platform; + const onErr = (err: Error | null) => { + if (err) console.error("[TaskSync] Sound playback error:", err.message); + }; + + try { + if (platform === "win32") { + spawn("powershell.exe", [ + "-Command", + "[System.Media.SystemSounds]::Exclamation.Play()", + ]).on("error", onErr); + } else if (platform === "darwin") { + execFile("afplay", ["/System/Library/Sounds/Tink.aiff"], onErr); + } else { + execFile( + "paplay", + ["/usr/share/sounds/freedesktop/stereo/message.oga"], + onErr, + ); + } + } catch (e) { + console.error("[TaskSync] Sound playback error:", e); + } +} diff --git a/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts new file mode 100644 index 0000000..da9463b --- /dev/null +++ b/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts @@ -0,0 +1,1314 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { + DEFAULT_HUMAN_LIKE_DELAY_MAX, + DEFAULT_HUMAN_LIKE_DELAY_MIN, + DEFAULT_SESSION_WARNING_HOURS, + HUMAN_DELAY_MAX_LOWER, + HUMAN_DELAY_MAX_UPPER, + HUMAN_DELAY_MIN_LOWER, + HUMAN_DELAY_MIN_UPPER, + MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT, + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + SESSION_WARNING_HOURS_MAX, + SESSION_WARNING_HOURS_MIN, +} from "../constants/remoteConstants"; +import { + broadcastAllSettingsToRemote, + buildSettingsPayload, + getAutopilotDefaultText, + handleAddAutopilotPrompt, + handleAddReusablePrompt, + handleEditAutopilotPrompt, + handleEditReusablePrompt, + handleRemoveAutopilotPrompt, + handleRemoveReusablePrompt, + handleReorderAutopilotPrompts, + handleSearchSlashCommands, + handleUpdateAutopilotSetting, + handleUpdateAutopilotText, + handleUpdateHumanDelayMax, + handleUpdateHumanDelayMin, + handleUpdateHumanDelaySetting, + handleUpdateInteractiveApprovalSetting, + handleUpdateMaxConsecutiveAutoResponses, + handleUpdateResponseTimeout, + handleUpdateSendWithCtrlEnterSetting, + handleUpdateSessionWarningHours, + handleUpdateSoundSetting, + loadSettings, + normalizeAutopilotText, + readResponseTimeoutMinutes, + saveAutopilotPrompts, + saveReusablePrompts, + updateSettingsUI, +} from "../webview/settingsHandlers"; + +// ─── Mock P factory ───────────────────────────────────────── + +function createMockP(overrides: Partial = {}) { + return { + _soundEnabled: true, + _interactiveApprovalEnabled: true, + _sendWithCtrlEnter: false, + _autopilotEnabled: false, + _autopilotText: "Continue", + _autopilotPrompts: [] as string[], + _autopilotIndex: 0, + _reusablePrompts: [] as any[], + _queueEnabled: true, + _humanLikeDelayEnabled: true, + _humanLikeDelayMin: DEFAULT_HUMAN_LIKE_DELAY_MIN, + _humanLikeDelayMax: DEFAULT_HUMAN_LIKE_DELAY_MAX, + _sessionWarningHours: DEFAULT_SESSION_WARNING_HOURS, + _consecutiveAutoResponses: 0, + _isUpdatingConfig: false, + _AUTOPILOT_DEFAULT_TEXT: "Continue", + _view: { + webview: { + postMessage: vi.fn(), + }, + }, + _remoteServer: null as any, + ...overrides, + } as any; +} + +function createMockConfig(values: Record = {}) { + return { + get: vi.fn((key: string, defaultValue?: any) => + key in values ? values[key] : defaultValue, + ), + update: vi.fn().mockResolvedValue(undefined), + inspect: vi.fn((_key: string): Record | undefined => { + if (_key in values) { + return { globalValue: values[_key] }; + } + return undefined; + }), + }; +} + +// ─── getAutopilotDefaultText ──────────────────────────────── + +describe("getAutopilotDefaultText", () => { + it("returns inspected default value when it has content", () => { + const config = createMockConfig(); + config.inspect.mockReturnValue({ defaultValue: "My Default" }); + const p = createMockP(); + expect(getAutopilotDefaultText(p, config as any)).toBe("My Default"); + }); + + it("returns p._AUTOPILOT_DEFAULT_TEXT when inspected default is empty", () => { + const config = createMockConfig(); + config.inspect.mockReturnValue({ defaultValue: "" }); + const p = createMockP({ _AUTOPILOT_DEFAULT_TEXT: "Fallback" }); + expect(getAutopilotDefaultText(p, config as any)).toBe("Fallback"); + }); + + it("returns p._AUTOPILOT_DEFAULT_TEXT when no inspected default", () => { + const config = createMockConfig(); + config.inspect.mockReturnValue({}); + const p = createMockP({ _AUTOPILOT_DEFAULT_TEXT: "Fallback" }); + expect(getAutopilotDefaultText(p, config as any)).toBe("Fallback"); + }); + + it("uses workspace configuration when no config param provided", () => { + const config = createMockConfig(); + config.inspect.mockReturnValue({ defaultValue: "FromConfig" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + expect(getAutopilotDefaultText(p)).toBe("FromConfig"); + }); +}); + +// ─── normalizeAutopilotText ───────────────────────────────── + +describe("normalizeAutopilotText", () => { + it("returns text when non-empty", () => { + const p = createMockP(); + const config = createMockConfig(); + config.inspect.mockReturnValue({ defaultValue: "Default" }); + expect(normalizeAutopilotText(p, "Custom text", config as any)).toBe( + "Custom text", + ); + }); + + it("returns default when text is empty", () => { + const p = createMockP(); + const config = createMockConfig(); + config.inspect.mockReturnValue({ defaultValue: "Default" }); + expect(normalizeAutopilotText(p, "", config as any)).toBe("Default"); + }); + + it("returns default when text is whitespace-only", () => { + const p = createMockP(); + const config = createMockConfig(); + config.inspect.mockReturnValue({ defaultValue: "Default" }); + expect(normalizeAutopilotText(p, " ", config as any)).toBe("Default"); + }); +}); + +// ─── readResponseTimeoutMinutes ───────────────────────────── + +describe("readResponseTimeoutMinutes", () => { + it("reads and normalizes config value", () => { + const config = createMockConfig({ responseTimeout: "30" }); + expect(readResponseTimeoutMinutes(config as any)).toBe(30); + }); + + it("uses default when value is invalid", () => { + const config = createMockConfig({ responseTimeout: "abc" }); + expect(readResponseTimeoutMinutes(config as any)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + }); + + it("falls back to workspace config when no param", () => { + const config = createMockConfig(); + config.get.mockReturnValue(String(RESPONSE_TIMEOUT_DEFAULT_MINUTES)); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + expect(readResponseTimeoutMinutes()).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + }); +}); + +// ─── loadSettings ─────────────────────────────────────────── + +describe("loadSettings", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("loads basic settings from config", () => { + const config = createMockConfig({ + notificationSound: false, + interactiveApproval: false, + sendWithCtrlEnter: true, + humanLikeDelay: false, + humanLikeDelayMin: 5, + humanLikeDelayMax: 10, + sessionWarningHours: 3, + }); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + + expect(p._soundEnabled).toBe(false); + expect(p._interactiveApprovalEnabled).toBe(false); + expect(p._sendWithCtrlEnter).toBe(true); + expect(p._humanLikeDelayEnabled).toBe(false); + }); + + it("migrates from old autoAnswer key", () => { + const config = createMockConfig({ autoAnswer: true }); + config.inspect.mockImplementation((key: string) => { + if (key === "autopilot") return undefined; + if (key === "autoAnswer") return { globalValue: true }; + if (key === "autopilotText") return undefined; + if (key === "autoAnswerText") return undefined; + return undefined; + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._autopilotEnabled).toBe(true); + }); + + it("uses new autopilot key when set", () => { + const config = createMockConfig({ autopilot: true }); + config.inspect.mockImplementation((key: string) => { + if (key === "autopilot") return { workspaceValue: true }; + if (key === "autopilotText") return undefined; + return undefined; + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._autopilotEnabled).toBe(true); + }); + + it("defaults autopilotEnabled to false when no keys are set", () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._autopilotEnabled).toBe(false); + }); + + it("loads autopilotPrompts from config", () => { + const config = createMockConfig({ + autopilotPrompts: ["prompt1", "prompt2", ""], + }); + config.inspect.mockImplementation((key: string) => { + if (key === "autopilot") return { workspaceValue: false }; + if (key === "autopilotText") return { workspaceValue: "text" }; + return undefined; + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + // Empty strings should be filtered out + expect(p._autopilotPrompts).toEqual(["prompt1", "prompt2"]); + }); + + it("clamps autopilotIndex when prompts array shrinks", () => { + const config = createMockConfig({ + autopilotPrompts: ["only-one"], + }); + config.inspect.mockImplementation((key: string) => { + if (key === "autopilot") return { workspaceValue: false }; + if (key === "autopilotText") return { workspaceValue: "text" }; + return undefined; + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _autopilotIndex: 5 }); + loadSettings(p); + expect(p._autopilotIndex).toBe(0); + }); + + it("loads reusable prompts with generated IDs", () => { + const config = createMockConfig({ + reusablePrompts: [ + { name: "fix", prompt: "Fix the bug" }, + { name: "test", prompt: "Write tests" }, + ], + }); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._reusablePrompts).toHaveLength(2); + expect(p._reusablePrompts[0].name).toBe("fix"); + expect(p._reusablePrompts[0].id).toMatch(/^rp_/); + }); + + it("ensures humanLikeDelayMin <= humanLikeDelayMax", () => { + const config = createMockConfig({ + humanLikeDelayMin: 15, + humanLikeDelayMax: 5, + }); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._humanLikeDelayMin).toBe(p._humanLikeDelayMax); + }); + + it("clamps sessionWarningHours within bounds", () => { + const config = createMockConfig({ + sessionWarningHours: 999, + }); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._sessionWarningHours).toBe(SESSION_WARNING_HOURS_MAX); + }); + + it("handles non-finite sessionWarningHours", () => { + const config = createMockConfig({ + sessionWarningHours: NaN, + }); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._sessionWarningHours).toBe(DEFAULT_SESSION_WARNING_HOURS); + }); + + it("migrates old autoAnswerText to autopilotText", () => { + const config = createMockConfig({ + autoAnswerText: "Old auto text", + }); + config.inspect.mockImplementation((key: string) => { + if (key === "autopilot") return undefined; + if (key === "autoAnswer") return undefined; + if (key === "autopilotText") return undefined; + if (key === "autoAnswerText") return { globalValue: "Old auto text" }; + return undefined; + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + loadSettings(p); + expect(p._autopilotText).toBe("Old auto text"); + }); + + it("falls back autopilotPrompts to autopilotText when no saved prompts", () => { + const config = createMockConfig({ + autopilotPrompts: [], + autopilotText: "Custom text", + }); + config.inspect.mockImplementation((key: string) => { + if (key === "autopilot") return { workspaceValue: false }; + if (key === "autopilotText") return { workspaceValue: "Custom text" }; + return undefined; + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _AUTOPILOT_DEFAULT_TEXT: "Continue" }); + loadSettings(p); + expect(p._autopilotPrompts).toEqual(["Custom text"]); + }); +}); + +// ─── buildSettingsPayload ─────────────────────────────────── + +describe("buildSettingsPayload", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("builds complete payload from P state", () => { + const config = createMockConfig({ + responseTimeout: "30", + maxConsecutiveAutoResponses: 5, + }); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _soundEnabled: false, + _autopilotEnabled: true, + _autopilotText: "Go ahead", + _autopilotPrompts: ["p1"], + _queueEnabled: false, + }); + + const payload = buildSettingsPayload(p); + expect(payload.soundEnabled).toBe(false); + expect(payload.autopilotEnabled).toBe(true); + expect(payload.autopilotText).toBe("Go ahead"); + expect(payload.autopilotPrompts).toEqual(["p1"]); + expect(payload.queueEnabled).toBe(false); + expect(payload.responseTimeout).toBe(30); + expect(payload.maxConsecutiveAutoResponses).toBe(5); + }); +}); + +// ─── updateSettingsUI ─────────────────────────────────────── + +describe("updateSettingsUI", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("posts updateSettings message to webview", () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + updateSettingsUI(p); + expect(p._view.webview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "updateSettings" }), + ); + }); +}); + +// ─── broadcastAllSettingsToRemote ──────────────────────────── + +describe("broadcastAllSettingsToRemote", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("broadcasts when remote server exists", () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const broadcast = vi.fn(); + const p = createMockP({ _remoteServer: { broadcast } }); + broadcastAllSettingsToRemote(p); + expect(broadcast).toHaveBeenCalledWith( + "settingsChanged", + expect.objectContaining({ soundEnabled: true }), + ); + }); + + it("does nothing when no remote server", () => { + const p = createMockP({ _remoteServer: null }); + broadcastAllSettingsToRemote(p); + // should not throw + }); +}); + +// ─── Settings update handlers ─────────────────────────────── + +describe("handleUpdateSoundSetting", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates sound and writes config", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateSoundSetting(p, false); + expect(p._soundEnabled).toBe(false); + expect(config.update).toHaveBeenCalledWith( + "notificationSound", + false, + vscode.ConfigurationTarget.Global, + ); + }); +}); + +describe("handleUpdateInteractiveApprovalSetting", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates interactive approval setting", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateInteractiveApprovalSetting(p, false); + expect(p._interactiveApprovalEnabled).toBe(false); + expect(config.update).toHaveBeenCalled(); + }); +}); + +describe("handleUpdateAutopilotSetting", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates autopilot and resets consecutive counter", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _consecutiveAutoResponses: 5 }); + await handleUpdateAutopilotSetting(p, true); + expect(p._autopilotEnabled).toBe(true); + expect(p._consecutiveAutoResponses).toBe(0); + }); +}); + +describe("handleUpdateAutopilotText", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("normalizes and saves autopilot text", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateAutopilotText(p, "New text"); + expect(p._autopilotText).toBe("New text"); + expect(config.update).toHaveBeenCalledWith( + "autopilotText", + "New text", + vscode.ConfigurationTarget.Workspace, + ); + }); +}); + +describe("handleUpdateSendWithCtrlEnterSetting", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates sendWithCtrlEnter setting", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateSendWithCtrlEnterSetting(p, true); + expect(p._sendWithCtrlEnter).toBe(true); + expect(config.update).toHaveBeenCalledWith( + "sendWithCtrlEnter", + true, + vscode.ConfigurationTarget.Global, + ); + }); +}); + +describe("handleUpdateHumanDelaySetting", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates humanLikeDelay setting", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateHumanDelaySetting(p, false); + expect(p._humanLikeDelayEnabled).toBe(false); + }); +}); + +// ─── Human delay min/max with cross-field adjustment ──────── + +describe("handleUpdateHumanDelayMin", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates min delay within valid range", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _humanLikeDelayMax: 10 }); + await handleUpdateHumanDelayMin(p, 3); + expect(p._humanLikeDelayMin).toBe(3); + }); + + it("adjusts max when min exceeds max", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _humanLikeDelayMax: 5 }); + await handleUpdateHumanDelayMin(p, 8); + expect(p._humanLikeDelayMin).toBe(8); + expect(p._humanLikeDelayMax).toBe(8); + // Should have written both min and max + expect(config.update).toHaveBeenCalledTimes(2); + }); + + it("ignores values below range", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + const originalMin = p._humanLikeDelayMin; + await handleUpdateHumanDelayMin(p, HUMAN_DELAY_MIN_LOWER - 1); + expect(p._humanLikeDelayMin).toBe(originalMin); + }); + + it("ignores values above range", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + const originalMin = p._humanLikeDelayMin; + await handleUpdateHumanDelayMin(p, HUMAN_DELAY_MIN_UPPER + 1); + expect(p._humanLikeDelayMin).toBe(originalMin); + }); +}); + +describe("handleUpdateHumanDelayMax", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates max delay within valid range", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _humanLikeDelayMin: 1 }); + await handleUpdateHumanDelayMax(p, 15); + expect(p._humanLikeDelayMax).toBe(15); + }); + + it("adjusts min when max drops below min", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _humanLikeDelayMin: 10 }); + await handleUpdateHumanDelayMax(p, 5); + expect(p._humanLikeDelayMax).toBe(5); + expect(p._humanLikeDelayMin).toBe(5); + expect(config.update).toHaveBeenCalledTimes(2); + }); + + it("ignores values below range", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + const originalMax = p._humanLikeDelayMax; + await handleUpdateHumanDelayMax(p, HUMAN_DELAY_MAX_LOWER - 1); + expect(p._humanLikeDelayMax).toBe(originalMax); + }); + + it("ignores values above range", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + const originalMax = p._humanLikeDelayMax; + await handleUpdateHumanDelayMax(p, HUMAN_DELAY_MAX_UPPER + 1); + expect(p._humanLikeDelayMax).toBe(originalMax); + }); +}); + +// ─── Session warning hours ────────────────────────────────── + +describe("handleUpdateSessionWarningHours", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("clamps value to max", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateSessionWarningHours(p, 999); + expect(p._sessionWarningHours).toBe(SESSION_WARNING_HOURS_MAX); + }); + + it("clamps value to min", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateSessionWarningHours(p, 0); + expect(p._sessionWarningHours).toBe(SESSION_WARNING_HOURS_MIN); + }); + + it("floors fractional values", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateSessionWarningHours(p, 3.7); + expect(p._sessionWarningHours).toBe(3); + }); + + it("rejects non-finite values", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + const original = p._sessionWarningHours; + await handleUpdateSessionWarningHours(p, NaN); + expect(p._sessionWarningHours).toBe(original); + }); +}); + +// ─── Max consecutive auto responses ───────────────────────── + +describe("handleUpdateMaxConsecutiveAutoResponses", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("clamps to limit", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateMaxConsecutiveAutoResponses(p, 9999); + expect(config.update).toHaveBeenCalledWith( + "maxConsecutiveAutoResponses", + MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT, + vscode.ConfigurationTarget.Workspace, + ); + }); + + it("clamps to minimum of 1", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateMaxConsecutiveAutoResponses(p, 0); + expect(config.update).toHaveBeenCalledWith( + "maxConsecutiveAutoResponses", + 1, + vscode.ConfigurationTarget.Workspace, + ); + }); + + it("rejects non-finite values", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateMaxConsecutiveAutoResponses(p, Infinity); + expect(config.update).not.toHaveBeenCalled(); + }); +}); + +// ─── Response timeout ─────────────────────────────────────── + +describe("handleUpdateResponseTimeout", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("normalizes and saves as string", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateResponseTimeout(p, 30); + expect(config.update).toHaveBeenCalledWith( + "responseTimeout", + "30", + vscode.ConfigurationTarget.Workspace, + ); + }); +}); + +// ─── Autopilot prompts management ─────────────────────────── + +describe("handleAddAutopilotPrompt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("adds non-empty prompt", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleAddAutopilotPrompt(p, "New prompt"); + expect(p._autopilotPrompts).toContain("New prompt"); + expect(p._view.webview.postMessage).toHaveBeenCalled(); + }); + + it("ignores empty prompt", async () => { + const p = createMockP(); + await handleAddAutopilotPrompt(p, " "); + expect(p._autopilotPrompts).toHaveLength(0); + }); +}); + +describe("handleEditAutopilotPrompt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("edits prompt at valid index", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _autopilotPrompts: ["old"] }); + await handleEditAutopilotPrompt(p, 0, "new"); + expect(p._autopilotPrompts[0]).toBe("new"); + }); + + it("rejects invalid index", async () => { + const p = createMockP({ _autopilotPrompts: ["old"] }); + await handleEditAutopilotPrompt(p, 5, "new"); + expect(p._autopilotPrompts[0]).toBe("old"); + }); + + it("rejects negative index", async () => { + const p = createMockP({ _autopilotPrompts: ["old"] }); + await handleEditAutopilotPrompt(p, -1, "new"); + expect(p._autopilotPrompts[0]).toBe("old"); + }); + + it("rejects empty prompt", async () => { + const p = createMockP({ _autopilotPrompts: ["old"] }); + await handleEditAutopilotPrompt(p, 0, " "); + expect(p._autopilotPrompts[0]).toBe("old"); + }); +}); + +describe("handleRemoveAutopilotPrompt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("removes prompt and adjusts index when before current", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b", "c"], + _autopilotIndex: 2, + }); + await handleRemoveAutopilotPrompt(p, 0); + expect(p._autopilotPrompts).toEqual(["b", "c"]); + expect(p._autopilotIndex).toBe(1); // decremented + }); + + it("clamps index when removing at/after current", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b"], + _autopilotIndex: 1, + }); + await handleRemoveAutopilotPrompt(p, 1); + expect(p._autopilotPrompts).toEqual(["a"]); + expect(p._autopilotIndex).toBe(0); // clamped + }); + + it("rejects invalid index", async () => { + const p = createMockP({ _autopilotPrompts: ["a"] }); + await handleRemoveAutopilotPrompt(p, -1); + expect(p._autopilotPrompts).toHaveLength(1); + }); + + it("rejects out-of-bounds index", async () => { + const p = createMockP({ _autopilotPrompts: ["a"] }); + await handleRemoveAutopilotPrompt(p, 5); + expect(p._autopilotPrompts).toHaveLength(1); + }); + + it("keeps index unchanged when removing after current index", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b", "c"], + _autopilotIndex: 0, + }); + await handleRemoveAutopilotPrompt(p, 2); + expect(p._autopilotPrompts).toEqual(["a", "b"]); + expect(p._autopilotIndex).toBe(0); // unchanged + }); +}); + +// ─── Autopilot prompt reordering with index tracking ──────── + +describe("handleReorderAutopilotPrompts", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("reorders prompts and tracks moved index", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b", "c"], + _autopilotIndex: 0, + }); + await handleReorderAutopilotPrompts(p, 0, 2); + expect(p._autopilotPrompts).toEqual(["b", "c", "a"]); + expect(p._autopilotIndex).toBe(2); // moved with the item + }); + + it("adjusts index when item moves past current", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b", "c"], + _autopilotIndex: 1, + }); + // Move item from before index (0) to after index (2) + await handleReorderAutopilotPrompts(p, 0, 2); + expect(p._autopilotIndex).toBe(0); // decremented + }); + + it("adjusts index when item moves before current", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b", "c"], + _autopilotIndex: 1, + }); + // Move item from after index (2) to at/before index (0) + await handleReorderAutopilotPrompts(p, 2, 0); + expect(p._autopilotIndex).toBe(2); // incremented + }); + + it("rejects same from/to index", async () => { + const p = createMockP({ _autopilotPrompts: ["a", "b"] }); + await handleReorderAutopilotPrompts(p, 0, 0); + // no change + expect(p._autopilotPrompts).toEqual(["a", "b"]); + }); + + it("rejects out-of-bounds indices", async () => { + const p = createMockP({ _autopilotPrompts: ["a", "b"] }); + await handleReorderAutopilotPrompts(p, -1, 0); + expect(p._autopilotPrompts).toEqual(["a", "b"]); + }); + + it("does not adjust index when move does not cross it", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _autopilotPrompts: ["a", "b", "c", "d"], + _autopilotIndex: 0, + }); + // Move within range that doesn't include index 0 + await handleReorderAutopilotPrompts(p, 2, 3); + expect(p._autopilotIndex).toBe(0); // unchanged + }); +}); + +// ─── Reusable prompts ─────────────────────────────────────── + +describe("handleAddReusablePrompt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("adds a new reusable prompt", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleAddReusablePrompt(p, "Fix Bug", "Fix the bug in the code"); + expect(p._reusablePrompts).toHaveLength(1); + expect(p._reusablePrompts[0].name).toBe("fix-bug"); + expect(p._reusablePrompts[0].prompt).toBe("Fix the bug in the code"); + }); + + it("normalizes name to lowercase with hyphens", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleAddReusablePrompt(p, " Run Tests ", "test"); + expect(p._reusablePrompts[0].name).toBe("run-tests"); + }); + + it("rejects empty name", async () => { + const p = createMockP(); + await handleAddReusablePrompt(p, "", "prompt"); + expect(p._reusablePrompts).toHaveLength(0); + }); + + it("rejects empty prompt", async () => { + const p = createMockP(); + await handleAddReusablePrompt(p, "name", ""); + expect(p._reusablePrompts).toHaveLength(0); + }); + + it("rejects duplicate names", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined); + + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "fix", prompt: "Fix it" }], + }); + await handleAddReusablePrompt(p, "Fix", "Another fix"); + expect(p._reusablePrompts).toHaveLength(1); + expect(vscode.window.showWarningMessage).toHaveBeenCalled(); + }); +}); + +describe("handleEditReusablePrompt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("edits an existing prompt", async () => { + const config = createMockConfig({}); + config.inspect.mockReturnValue({ defaultValue: "Continue" }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "fix", prompt: "Old" }], + }); + await handleEditReusablePrompt(p, "rp_1", "fix", "New prompt"); + expect(p._reusablePrompts[0].prompt).toBe("New prompt"); + }); + + it("rejects duplicate name when renaming", async () => { + vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined); + + const p = createMockP({ + _reusablePrompts: [ + { id: "rp_1", name: "fix", prompt: "Fix" }, + { id: "rp_2", name: "test", prompt: "Test" }, + ], + }); + await handleEditReusablePrompt(p, "rp_1", "test", "New prompt"); + // Should not have changed + expect(p._reusablePrompts[0].name).toBe("fix"); + expect(vscode.window.showWarningMessage).toHaveBeenCalled(); + }); + + it("does nothing for non-existent ID", async () => { + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "fix", prompt: "Fix" }], + }); + await handleEditReusablePrompt(p, "rp_999", "fix", "New"); + expect(p._reusablePrompts[0].prompt).toBe("Fix"); + }); + + it("rejects empty name in edit", async () => { + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "fix", prompt: "Fix" }], + }); + await handleEditReusablePrompt(p, "rp_1", "", "New prompt"); + expect(p._reusablePrompts[0].prompt).toBe("Fix"); + }); + + it("rejects empty prompt in edit", async () => { + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "fix", prompt: "Fix" }], + }); + await handleEditReusablePrompt(p, "rp_1", "fix", " "); + expect(p._reusablePrompts[0].prompt).toBe("Fix"); + }); +}); + +describe("handleRemoveReusablePrompt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("removes a reusable prompt by ID", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _reusablePrompts: [ + { id: "rp_1", name: "fix", prompt: "Fix" }, + { id: "rp_2", name: "test", prompt: "Test" }, + ], + }); + await handleRemoveReusablePrompt(p, "rp_1"); + expect(p._reusablePrompts).toHaveLength(1); + expect(p._reusablePrompts[0].id).toBe("rp_2"); + }); +}); + +// ─── Slash command search ─────────────────────────────────── + +describe("handleSearchSlashCommands", () => { + it("returns matching prompts by name", () => { + const p = createMockP({ + _reusablePrompts: [ + { id: "rp_1", name: "fix-bug", prompt: "Fix the bug" }, + { id: "rp_2", name: "write-test", prompt: "Write tests" }, + { id: "rp_3", name: "refactor", prompt: "Refactor code" }, + ], + }); + handleSearchSlashCommands(p, "fix"); + expect(p._view.webview.postMessage).toHaveBeenCalledWith({ + type: "slashCommandResults", + prompts: [{ id: "rp_1", name: "fix-bug", prompt: "Fix the bug" }], + }); + }); + + it("returns matching prompts by prompt content", () => { + const p = createMockP({ + _reusablePrompts: [ + { id: "rp_1", name: "fix-bug", prompt: "Fix the bug" }, + { id: "rp_2", name: "write-test", prompt: "Write tests" }, + ], + }); + handleSearchSlashCommands(p, "tests"); + expect(p._view.webview.postMessage).toHaveBeenCalledWith({ + type: "slashCommandResults", + prompts: [{ id: "rp_2", name: "write-test", prompt: "Write tests" }], + }); + }); + + it("is case-insensitive", () => { + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "Fix-Bug", prompt: "FIX" }], + }); + handleSearchSlashCommands(p, "fix"); + const call = p._view.webview.postMessage.mock.calls[0][0]; + expect(call.prompts).toHaveLength(1); + }); +}); + +// ─── saveReusablePrompts ──────────────────────────────────── + +describe("saveReusablePrompts", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("saves prompts without IDs", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ + _reusablePrompts: [{ id: "rp_1", name: "fix", prompt: "Fix it" }], + }); + await saveReusablePrompts(p); + expect(config.update).toHaveBeenCalledWith( + "reusablePrompts", + [{ name: "fix", prompt: "Fix it" }], + vscode.ConfigurationTarget.Global, + ); + }); +}); + +// ─── saveAutopilotPrompts ─────────────────────────────────── + +describe("saveAutopilotPrompts", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("saves autopilot prompts array", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP({ _autopilotPrompts: ["a", "b"] }); + await saveAutopilotPrompts(p); + expect(config.update).toHaveBeenCalledWith( + "autopilotPrompts", + ["a", "b"], + vscode.ConfigurationTarget.Global, + ); + }); +}); + +// ─── withConfigGuard error handling ───────────────────────── + +describe("config guard error handling", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("catches and logs config update errors", async () => { + const config = createMockConfig({}); + config.update.mockRejectedValue(new Error("config write failed")); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const p = createMockP(); + await handleUpdateSoundSetting(p, false); + // Should not throw, and should have logged error + expect(errorSpy).toHaveBeenCalledWith( + "[TaskSync] Config update failed:", + expect.any(Error), + ); + // _isUpdatingConfig should be reset (finally block) + expect(p._isUpdatingConfig).toBe(false); + }); +}); diff --git a/tasksync-chat/src/webview/settingsHandlers.test.ts b/tasksync-chat/src/webview/settingsHandlers.test.ts new file mode 100644 index 0000000..8324bd4 --- /dev/null +++ b/tasksync-chat/src/webview/settingsHandlers.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + RESPONSE_TIMEOUT_ALLOWED_VALUES, + RESPONSE_TIMEOUT_DEFAULT_MINUTES, +} from "../constants/remoteConstants"; +import { normalizeResponseTimeout } from "../webview/settingsHandlers"; + +describe("normalizeResponseTimeout", () => { + it("accepts valid allowed values", () => { + for (const v of RESPONSE_TIMEOUT_ALLOWED_VALUES) { + expect(normalizeResponseTimeout(v)).toBe(v); + } + }); + + it("returns default for non-allowed numbers", () => { + expect(normalizeResponseTimeout(7)).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + expect(normalizeResponseTimeout(99)).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + expect(normalizeResponseTimeout(-1)).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + }); + + it("returns default for non-integer numbers", () => { + expect(normalizeResponseTimeout(2.5)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + expect(normalizeResponseTimeout(NaN)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + expect(normalizeResponseTimeout(Infinity)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + }); + + it("parses valid string values", () => { + expect(normalizeResponseTimeout("5")).toBe(5); + expect(normalizeResponseTimeout("10")).toBe(10); + expect(normalizeResponseTimeout("60")).toBe(60); + }); + + it("returns default for invalid string values", () => { + expect(normalizeResponseTimeout("abc")).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + expect(normalizeResponseTimeout("2.5")).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + }); + + it("returns default for empty/whitespace strings", () => { + expect(normalizeResponseTimeout("")).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + expect(normalizeResponseTimeout(" ")).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + }); + + it("returns default for non-number/string types", () => { + expect(normalizeResponseTimeout(null)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + expect(normalizeResponseTimeout(undefined)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + expect(normalizeResponseTimeout(true)).toBe( + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + ); + expect(normalizeResponseTimeout({})).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + expect(normalizeResponseTimeout([])).toBe(RESPONSE_TIMEOUT_DEFAULT_MINUTES); + }); +}); diff --git a/tasksync-chat/src/webview/settingsHandlers.ts b/tasksync-chat/src/webview/settingsHandlers.ts new file mode 100644 index 0000000..0580f02 --- /dev/null +++ b/tasksync-chat/src/webview/settingsHandlers.ts @@ -0,0 +1,648 @@ +import * as vscode from "vscode"; +import { + CONFIG_SECTION, + DEFAULT_HUMAN_LIKE_DELAY_MAX, + DEFAULT_HUMAN_LIKE_DELAY_MIN, + DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, + DEFAULT_SESSION_WARNING_HOURS, + HUMAN_DELAY_MAX_LOWER, + HUMAN_DELAY_MAX_UPPER, + HUMAN_DELAY_MIN_LOWER, + HUMAN_DELAY_MIN_UPPER, + MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT, + RESPONSE_TIMEOUT_ALLOWED_VALUES, + RESPONSE_TIMEOUT_DEFAULT_MINUTES, + SESSION_WARNING_HOURS_MAX, + SESSION_WARNING_HOURS_MIN, +} from "../constants/remoteConstants"; +import type { P, ReusablePrompt, ToWebviewMessage } from "./webviewTypes"; +import { generateId } from "./webviewUtils"; + +/** + * Guard config writes with _isUpdatingConfig flag to prevent re-entry. + * Catches and logs errors to prevent unhandled promise rejections when + * called fire-and-forget from the synchronous message router. + */ +async function withConfigGuard(p: P, fn: () => Promise): Promise { + p._isUpdatingConfig = true; + try { + await fn(); + } catch (e) { + console.error("[TaskSync] Config update failed:", e); + } finally { + p._isUpdatingConfig = false; + } +} + +export function getAutopilotDefaultText( + p: P, + config?: vscode.WorkspaceConfiguration, +): string { + const settings = config ?? vscode.workspace.getConfiguration(CONFIG_SECTION); + const inspected = settings.inspect("autopilotText"); + const defaultValue = + typeof inspected?.defaultValue === "string" ? inspected.defaultValue : ""; + return defaultValue.trim().length > 0 + ? defaultValue + : p._AUTOPILOT_DEFAULT_TEXT; +} + +export function normalizeAutopilotText( + p: P, + text: string, + config?: vscode.WorkspaceConfiguration, +): string { + const defaultAutopilotText = getAutopilotDefaultText(p, config); + return text.trim().length > 0 ? text : defaultAutopilotText; +} + +export function normalizeResponseTimeout(value: unknown): number { + let parsedValue: number; + + if (typeof value === "number") { + parsedValue = value; + } else if (typeof value === "string") { + const normalizedValue = value.trim(); + if (normalizedValue.length === 0) { + return RESPONSE_TIMEOUT_DEFAULT_MINUTES; + } + parsedValue = Number(normalizedValue); + } else { + return RESPONSE_TIMEOUT_DEFAULT_MINUTES; + } + + if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue)) { + return RESPONSE_TIMEOUT_DEFAULT_MINUTES; + } + if (!RESPONSE_TIMEOUT_ALLOWED_VALUES.has(parsedValue)) { + return RESPONSE_TIMEOUT_DEFAULT_MINUTES; + } + return parsedValue; +} + +export function readResponseTimeoutMinutes( + config?: vscode.WorkspaceConfiguration, +): number { + const settings = config ?? vscode.workspace.getConfiguration(CONFIG_SECTION); + const configuredTimeout = settings.get( + "responseTimeout", + String(RESPONSE_TIMEOUT_DEFAULT_MINUTES), + ); + return normalizeResponseTimeout(configuredTimeout); +} + +export function loadSettings(p: P): void { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + p._soundEnabled = config.get("notificationSound", true); + p._interactiveApprovalEnabled = config.get( + "interactiveApproval", + true, + ); + + // Backward-compatible migration: read old 'autoAnswer'/'autoAnswerText' keys + const inspectedAutopilot = config.inspect("autopilot"); + const hasNewAutopilotKey = + inspectedAutopilot?.globalValue !== undefined || + inspectedAutopilot?.workspaceValue !== undefined || + inspectedAutopilot?.workspaceFolderValue !== undefined; + + if (!hasNewAutopilotKey) { + const oldVal = config.inspect("autoAnswer"); + const hasOldKey = + oldVal?.globalValue !== undefined || + oldVal?.workspaceValue !== undefined || + oldVal?.workspaceFolderValue !== undefined; + if (hasOldKey) { + p._autopilotEnabled = config.get("autoAnswer", false); + } else { + p._autopilotEnabled = false; + } + } else { + p._autopilotEnabled = config.get("autopilot", false); + } + + const defaultAutopilotText = getAutopilotDefaultText(p, config); + const inspectedAutopilotText = config.inspect("autopilotText"); + const hasNewAutopilotTextKey = + inspectedAutopilotText?.globalValue !== undefined || + inspectedAutopilotText?.workspaceValue !== undefined || + inspectedAutopilotText?.workspaceFolderValue !== undefined; + + if (!hasNewAutopilotTextKey) { + const oldTextVal = config.inspect("autoAnswerText"); + const hasOldTextKey = + oldTextVal?.globalValue !== undefined || + oldTextVal?.workspaceValue !== undefined || + oldTextVal?.workspaceFolderValue !== undefined; + if (hasOldTextKey) { + const oldText = config.get( + "autoAnswerText", + defaultAutopilotText, + ); + p._autopilotText = normalizeAutopilotText(p, oldText, config); + } else { + p._autopilotText = defaultAutopilotText; + } + } else { + const configuredAutopilotText = config.get( + "autopilotText", + defaultAutopilotText, + ); + p._autopilotText = normalizeAutopilotText( + p, + configuredAutopilotText, + config, + ); + } + + // Load autopilot prompts array (with fallback to autopilotText for migration) + const savedAutopilotPrompts = config.get("autopilotPrompts", []); + if (savedAutopilotPrompts.length > 0) { + p._autopilotPrompts = savedAutopilotPrompts.filter( + (pr: string) => pr.trim().length > 0, + ); + } else if (p._autopilotText && p._autopilotText !== defaultAutopilotText) { + p._autopilotPrompts = [p._autopilotText]; + } else { + p._autopilotPrompts = []; + } + if (p._autopilotIndex >= p._autopilotPrompts.length) { + p._autopilotIndex = 0; + } + + const savedPrompts = config.get>( + "reusablePrompts", + [], + ); + p._reusablePrompts = savedPrompts.map( + (pr: { name: string; prompt: string }) => ({ + id: generateId("rp"), + name: pr.name, + prompt: pr.prompt, + }), + ); + + // Load human-like delay settings + p._humanLikeDelayEnabled = config.get("humanLikeDelay", true); + p._humanLikeDelayMin = config.get( + "humanLikeDelayMin", + DEFAULT_HUMAN_LIKE_DELAY_MIN, + ); + p._humanLikeDelayMax = config.get( + "humanLikeDelayMax", + DEFAULT_HUMAN_LIKE_DELAY_MAX, + ); + const configuredWarningHours = config.get( + "sessionWarningHours", + DEFAULT_SESSION_WARNING_HOURS, + ); + p._sessionWarningHours = Number.isFinite(configuredWarningHours) + ? Math.min( + SESSION_WARNING_HOURS_MAX, + Math.max(SESSION_WARNING_HOURS_MIN, Math.floor(configuredWarningHours)), + ) + : DEFAULT_SESSION_WARNING_HOURS; + p._sendWithCtrlEnter = config.get("sendWithCtrlEnter", false); + // Ensure min <= max + if (p._humanLikeDelayMin > p._humanLikeDelayMax) { + p._humanLikeDelayMin = p._humanLikeDelayMax; + } +} + +export async function saveReusablePrompts(p: P): Promise { + const promptsToSave = p._reusablePrompts.map((pr: ReusablePrompt) => ({ + name: pr.name, + prompt: pr.prompt, + })); + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "reusablePrompts", + promptsToSave, + vscode.ConfigurationTarget.Global, + ); + }); +} + +/** Build canonical settings payload — SSOT for all settings fields. */ +export function buildSettingsPayload(p: P): { + soundEnabled: boolean; + interactiveApprovalEnabled: boolean; + sendWithCtrlEnter: boolean; + autopilotEnabled: boolean; + autopilotText: string; + autopilotPrompts: string[]; + reusablePrompts: ReusablePrompt[]; + responseTimeout: number; + sessionWarningHours: number; + maxConsecutiveAutoResponses: number; + humanLikeDelayEnabled: boolean; + humanLikeDelayMin: number; + humanLikeDelayMax: number; + queueEnabled: boolean; +} { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + return { + soundEnabled: p._soundEnabled, + interactiveApprovalEnabled: p._interactiveApprovalEnabled, + sendWithCtrlEnter: p._sendWithCtrlEnter, + autopilotEnabled: p._autopilotEnabled, + autopilotText: p._autopilotText, + autopilotPrompts: p._autopilotPrompts, + reusablePrompts: p._reusablePrompts, + responseTimeout: readResponseTimeoutMinutes(config), + sessionWarningHours: p._sessionWarningHours, + maxConsecutiveAutoResponses: config.get( + "maxConsecutiveAutoResponses", + DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, + ), + humanLikeDelayEnabled: p._humanLikeDelayEnabled, + humanLikeDelayMin: p._humanLikeDelayMin, + humanLikeDelayMax: p._humanLikeDelayMax, + queueEnabled: p._queueEnabled, + }; +} + +export function updateSettingsUI(p: P): void { + const payload = buildSettingsPayload(p); + p._view?.webview.postMessage({ + type: "updateSettings", + ...payload, + } satisfies ToWebviewMessage); +} + +/** Broadcast ALL current settings to remote clients. */ +export function broadcastAllSettingsToRemote(p: P): void { + if (!p._remoteServer) return; + p._remoteServer.broadcast("settingsChanged", buildSettingsPayload(p)); +} + +export async function saveAutopilotPrompts(p: P): Promise { + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "autopilotPrompts", + p._autopilotPrompts, + vscode.ConfigurationTarget.Global, + ); + }); +} + +export async function handleUpdateSoundSetting( + p: P, + enabled: boolean, +): Promise { + p._soundEnabled = enabled; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "notificationSound", + enabled, + vscode.ConfigurationTarget.Global, + ); + }); +} + +export async function handleUpdateInteractiveApprovalSetting( + p: P, + enabled: boolean, +): Promise { + p._interactiveApprovalEnabled = enabled; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "interactiveApproval", + enabled, + vscode.ConfigurationTarget.Global, + ); + }); +} + +export async function handleUpdateAutopilotSetting( + p: P, + enabled: boolean, +): Promise { + p._autopilotEnabled = enabled; + p._consecutiveAutoResponses = 0; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "autopilot", + enabled, + vscode.ConfigurationTarget.Workspace, + ); + }); + // Remote broadcast handled by onDidChangeConfiguration → broadcastAllSettingsToRemote +} + +export async function handleUpdateAutopilotText( + p: P, + text: string, +): Promise { + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const normalizedText = normalizeAutopilotText(p, text, config); + p._autopilotText = normalizedText; + await config.update( + "autopilotText", + normalizedText, + vscode.ConfigurationTarget.Workspace, + ); + }); +} + +export async function handleAddAutopilotPrompt( + p: P, + prompt: string, +): Promise { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) return; + p._autopilotPrompts.push(trimmedPrompt); + await saveAutopilotPrompts(p); + updateSettingsUI(p); +} + +export async function handleEditAutopilotPrompt( + p: P, + index: number, + prompt: string, +): Promise { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt || index < 0 || index >= p._autopilotPrompts.length) + return; + p._autopilotPrompts[index] = trimmedPrompt; + await saveAutopilotPrompts(p); + updateSettingsUI(p); +} + +export async function handleRemoveAutopilotPrompt( + p: P, + index: number, +): Promise { + if (index < 0 || index >= p._autopilotPrompts.length) return; + p._autopilotPrompts.splice(index, 1); + if (p._autopilotIndex > index) { + p._autopilotIndex--; + } else if (p._autopilotIndex >= p._autopilotPrompts.length) { + p._autopilotIndex = 0; + } + await saveAutopilotPrompts(p); + updateSettingsUI(p); +} + +export async function handleReorderAutopilotPrompts( + p: P, + fromIndex: number, + toIndex: number, +): Promise { + if ( + fromIndex < 0 || + fromIndex >= p._autopilotPrompts.length || + toIndex < 0 || + toIndex >= p._autopilotPrompts.length || + fromIndex === toIndex + ) { + return; + } + + if (p._autopilotIndex === fromIndex) { + p._autopilotIndex = toIndex; + } else if (fromIndex < p._autopilotIndex && toIndex >= p._autopilotIndex) { + p._autopilotIndex--; + } else if (fromIndex > p._autopilotIndex && toIndex <= p._autopilotIndex) { + p._autopilotIndex++; + } + + const [removed] = p._autopilotPrompts.splice(fromIndex, 1); + p._autopilotPrompts.splice(toIndex, 0, removed); + await saveAutopilotPrompts(p); + updateSettingsUI(p); +} + +export async function handleUpdateResponseTimeout( + p: P, + value: number, +): Promise { + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "responseTimeout", + String(normalizeResponseTimeout(value)), + vscode.ConfigurationTarget.Workspace, + ); + }); +} + +export async function handleUpdateSessionWarningHours( + p: P, + value: number, +): Promise { + if (!Number.isFinite(value)) return; + const normalizedValue = Math.min( + SESSION_WARNING_HOURS_MAX, + Math.max(SESSION_WARNING_HOURS_MIN, Math.floor(value)), + ); + p._sessionWarningHours = normalizedValue; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "sessionWarningHours", + normalizedValue, + vscode.ConfigurationTarget.Workspace, + ); + }); +} + +export async function handleUpdateMaxConsecutiveAutoResponses( + p: P, + value: number, +): Promise { + if (!Number.isFinite(value)) return; + const clamped = Math.min( + MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT, + Math.max(1, Math.floor(value)), + ); + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "maxConsecutiveAutoResponses", + clamped, + vscode.ConfigurationTarget.Workspace, + ); + }); +} + +export async function handleUpdateHumanDelaySetting( + p: P, + enabled: boolean, +): Promise { + p._humanLikeDelayEnabled = enabled; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "humanLikeDelay", + enabled, + vscode.ConfigurationTarget.Workspace, + ); + }); +} + +export async function handleUpdateHumanDelayMin( + p: P, + value: number, +): Promise { + if (value >= HUMAN_DELAY_MIN_LOWER && value <= HUMAN_DELAY_MIN_UPPER) { + p._humanLikeDelayMin = value; + let adjustedMax = false; + if (p._humanLikeDelayMin > p._humanLikeDelayMax) { + p._humanLikeDelayMax = p._humanLikeDelayMin; + adjustedMax = true; + } + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "humanLikeDelayMin", + value, + vscode.ConfigurationTarget.Workspace, + ); + if (adjustedMax) { + await config.update( + "humanLikeDelayMax", + p._humanLikeDelayMax, + vscode.ConfigurationTarget.Workspace, + ); + } + }); + } +} + +export async function handleUpdateHumanDelayMax( + p: P, + value: number, +): Promise { + if (value >= HUMAN_DELAY_MAX_LOWER && value <= HUMAN_DELAY_MAX_UPPER) { + p._humanLikeDelayMax = value; + let adjustedMin = false; + if (p._humanLikeDelayMax < p._humanLikeDelayMin) { + p._humanLikeDelayMin = p._humanLikeDelayMax; + adjustedMin = true; + } + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "humanLikeDelayMax", + value, + vscode.ConfigurationTarget.Workspace, + ); + if (adjustedMin) { + await config.update( + "humanLikeDelayMin", + p._humanLikeDelayMin, + vscode.ConfigurationTarget.Workspace, + ); + } + }); + } +} + +export async function handleUpdateSendWithCtrlEnterSetting( + p: P, + enabled: boolean, +): Promise { + p._sendWithCtrlEnter = enabled; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "sendWithCtrlEnter", + enabled, + vscode.ConfigurationTarget.Global, + ); + }); +} + +export async function handleAddReusablePrompt( + p: P, + name: string, + prompt: string, +): Promise { + const trimmedName = name.trim().toLowerCase().replace(/\s+/g, "-"); + const trimmedPrompt = prompt.trim(); + if (!trimmedName || !trimmedPrompt) return; + + if ( + p._reusablePrompts.some( + (pr: ReusablePrompt) => pr.name.toLowerCase() === trimmedName, + ) + ) { + vscode.window.showWarningMessage( + `A prompt with name "/${trimmedName}" already exists.`, + ); + return; + } + + const newPrompt: ReusablePrompt = { + id: generateId("rp"), + name: trimmedName, + prompt: trimmedPrompt, + }; + p._reusablePrompts.push(newPrompt); + await saveReusablePrompts(p); + updateSettingsUI(p); +} + +export async function handleEditReusablePrompt( + p: P, + id: string, + name: string, + prompt: string, +): Promise { + const trimmedName = name.trim().toLowerCase().replace(/\s+/g, "-"); + const trimmedPrompt = prompt.trim(); + if (!trimmedName || !trimmedPrompt) return; + + const existingPrompt = p._reusablePrompts.find( + (pr: ReusablePrompt) => pr.id === id, + ); + if (!existingPrompt) return; + + if ( + p._reusablePrompts.some( + (pr: ReusablePrompt) => + pr.id !== id && pr.name.toLowerCase() === trimmedName, + ) + ) { + vscode.window.showWarningMessage( + `A prompt with name "/${trimmedName}" already exists.`, + ); + return; + } + + existingPrompt.name = trimmedName; + existingPrompt.prompt = trimmedPrompt; + await saveReusablePrompts(p); + updateSettingsUI(p); +} + +export async function handleRemoveReusablePrompt( + p: P, + id: string, +): Promise { + p._reusablePrompts = p._reusablePrompts.filter( + (pr: ReusablePrompt) => pr.id !== id, + ); + await saveReusablePrompts(p); + updateSettingsUI(p); +} + +export function handleSearchSlashCommands(p: P, query: string): void { + const queryLower = query.toLowerCase(); + const matchingPrompts = p._reusablePrompts.filter( + (pr: ReusablePrompt) => + pr.name.toLowerCase().includes(queryLower) || + pr.prompt.toLowerCase().includes(queryLower), + ); + p._view?.webview.postMessage({ + type: "slashCommandResults", + prompts: matchingPrompts, + } satisfies ToWebviewMessage); +} diff --git a/tasksync-chat/src/webview/toolCallHandler.ts b/tasksync-chat/src/webview/toolCallHandler.ts new file mode 100644 index 0000000..6e4e6e1 --- /dev/null +++ b/tasksync-chat/src/webview/toolCallHandler.ts @@ -0,0 +1,475 @@ +/** + * Tool call handling logic extracted from webviewProvider.ts. + * Contains waitForUserResponse, timeout handling, and request cancellation. + */ +import * as vscode from "vscode"; +import { + CONFIG_SECTION, + DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, +} from "../constants/remoteConstants"; + +import { isApprovalQuestion, parseChoices } from "./choiceParser"; +import * as settingsH from "./settingsHandlers"; +import type { + P, + ToolCallEntry, + ToWebviewMessage, + UserResponseResult, +} from "./webviewTypes"; +import { VIEW_TYPE } from "./webviewTypes"; +import { + broadcastToolCallCompleted, + debugLog, + generateId, + hasQueuedItems, + markSessionTerminated, + notifyQueueChanged, +} from "./webviewUtils"; + +/** + * Cancel any pending request superseded by a new one. + */ +export function cancelSupersededPendingRequest(p: P): void { + if (!p._currentToolCallId || !p._pendingRequests.has(p._currentToolCallId)) { + return; + } + + debugLog("[TaskSync] Superseding pending request:", p._currentToolCallId); + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + const oldToolCallId = p._currentToolCallId; + const oldResolve = p._pendingRequests.get(oldToolCallId); + if (oldResolve) { + oldResolve({ + value: "[CANCELLED: New request superseded this one]", + queue: hasQueuedItems(p), + attachments: [], + cancelled: true, + } as UserResponseResult); + p._pendingRequests.delete(oldToolCallId); + + const oldEntry = p._currentSessionCallsMap.get(oldToolCallId); + if (oldEntry && oldEntry.status === "pending") { + oldEntry.status = "cancelled"; + oldEntry.response = "[Superseded by new request]"; + p._updateCurrentSessionUI(); + } + console.error( + `[TaskSync] Previous request ${oldToolCallId} was superseded by new request`, + ); + } +} + +/** + * Core tool call handler — waits for user response via webview. + */ +export async function waitForUserResponse( + p: P, + question: string, + summary?: string, +): Promise { + debugLog( + "[TaskSync] waitForUserResponse — question:", + question.slice(0, 80), + "currentToolCallId:", + p._currentToolCallId, + "pendingRequests:", + p._pendingRequests.size, + ); + // AI called askUser — it's no longer processing, it's waiting for user input + p._aiTurnActive = false; + + // Auto-start new session if previous session was terminated + if (p._sessionTerminated) { + debugLog( + "[TaskSync] waitForUserResponse — session was terminated, auto-starting new session", + ); + p.startNewSession(); + } + + // Start session timer on first tool call + if (p._sessionStartTime === null) { + p._sessionStartTime = Date.now(); + p._sessionFrozenElapsed = null; + p._startSessionTimerInterval(); + p._updateViewTitle(); + } + + if (p._autopilotEnabled && !hasQueuedItems(p)) { + debugLog( + "[TaskSync] waitForUserResponse — autopilot enabled, no queue items, auto-responding", + ); + cancelSupersededPendingRequest(p); + + p._consecutiveAutoResponses++; + + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const maxConsecutive = config.get( + "maxConsecutiveAutoResponses", + DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, + ); + + if (p._consecutiveAutoResponses > maxConsecutive) { + debugLog( + `[TaskSync] waitForUserResponse — autopilot limit reached (${p._consecutiveAutoResponses}/${maxConsecutive}), disabling`, + ); + p._autopilotEnabled = false; + await config.update( + "autopilot", + false, + vscode.ConfigurationTarget.Workspace, + ); + p._updateSettingsUI(); + vscode.window.showWarningMessage( + `TaskSync: Auto-response limit (${maxConsecutive}) reached. Waiting for response or timeout.`, + ); + // Fall through to pending request flow with timeout timer + } else { + const toolCallId = generateId("tc"); + p._currentToolCallId = toolCallId; + + await p._applyHumanLikeDelay("Autopilot"); + + if (!p._autopilotEnabled || p._currentToolCallId !== toolCallId) { + // State changed during delay — fall through + } else { + let effectiveText: string; + if (p._autopilotPrompts.length > 0) { + effectiveText = p._autopilotPrompts[p._autopilotIndex]; + p._autopilotIndex = + (p._autopilotIndex + 1) % p._autopilotPrompts.length; + } else { + effectiveText = settingsH.normalizeAutopilotText(p, p._autopilotText); + } + debugLog( + `[TaskSync] waitForUserResponse — autopilot auto-responding with: "${effectiveText.slice(0, 60)}" (${p._consecutiveAutoResponses}/${maxConsecutive})`, + ); + vscode.window.showInformationMessage( + `TaskSync: Autopilot auto-responded. (${p._consecutiveAutoResponses}/${maxConsecutive})`, + ); + + const entry: ToolCallEntry = { + id: toolCallId, + prompt: question, + summary: summary || undefined, + response: effectiveText, + timestamp: Date.now(), + isFromQueue: false, + status: "completed", + }; + p._currentSessionCalls.unshift(entry); + p._currentSessionCallsMap.set(entry.id, entry); + p._updateViewTitle(); + p._updateCurrentSessionUI(); + p._currentToolCallId = null; + + broadcastToolCallCompleted(p, entry); + + return { + value: effectiveText, + queue: hasQueuedItems(p), + attachments: [], + }; + } + } + } + + // If view is not available, open the sidebar first + if (!p._view) { + await vscode.commands.executeCommand(`${VIEW_TYPE}.focus`); + + let waited = 0; + while (!p._view && waited < p._VIEW_OPEN_TIMEOUT_MS) { + await new Promise((resolve) => + setTimeout(resolve, p._VIEW_OPEN_POLL_INTERVAL_MS), + ); + waited += p._VIEW_OPEN_POLL_INTERVAL_MS; + } + + if (!p._view) { + console.error( + `[TaskSync] Failed to open sidebar view after waiting ${p._VIEW_OPEN_TIMEOUT_MS}ms`, + ); + throw new Error( + `Failed to open TaskSync sidebar after ${p._VIEW_OPEN_TIMEOUT_MS}ms. The webview may not be properly initialized.`, + ); + } + } + + cancelSupersededPendingRequest(p); + + const toolCallId = generateId("tc"); + p._currentToolCallId = toolCallId; + + // Check if queue has prompts — auto-respond + if (hasQueuedItems(p)) { + debugLog( + `[TaskSync] waitForUserResponse — queue has ${p._promptQueue.length} items, attempting auto-respond from queue`, + ); + const queuedPrompt = p._promptQueue.shift(); + if (queuedPrompt) { + notifyQueueChanged(p); + + await p._applyHumanLikeDelay("Queue"); + + if (!p._queueEnabled || p._currentToolCallId !== toolCallId) { + debugLog( + "[TaskSync] waitForUserResponse — queue disabled or toolCallId changed during delay, re-queuing", + ); + p._promptQueue.unshift(queuedPrompt); + notifyQueueChanged(p); + } else { + debugLog( + `[TaskSync] waitForUserResponse — queue auto-responding with: "${queuedPrompt.prompt.slice(0, 60)}"`, + ); + const entry: ToolCallEntry = { + id: toolCallId, + prompt: question, + summary: summary || undefined, + response: queuedPrompt.prompt, + timestamp: Date.now(), + isFromQueue: true, + status: "completed", + }; + p._currentSessionCalls.unshift(entry); + p._currentSessionCallsMap.set(entry.id, entry); + p._updateViewTitle(); + p._updateCurrentSessionUI(); + p._currentToolCallId = null; + + broadcastToolCallCompleted(p, entry); + + return { + value: queuedPrompt.prompt, + queue: hasQueuedItems(p), + attachments: queuedPrompt.attachments || [], + }; + } + } + } + + p._view.show(true); + + debugLog( + `[TaskSync] waitForUserResponse — creating pending entry, toolCallId: ${toolCallId}, webviewReady: ${p._webviewReady}`, + ); + // Add pending entry to current session + const pendingEntry: ToolCallEntry = { + id: toolCallId, + prompt: question, + summary: summary || undefined, + response: "", + timestamp: Date.now(), + isFromQueue: false, + status: "pending", + }; + p._currentSessionCalls.unshift(pendingEntry); + p._currentSessionCallsMap.set(toolCallId, pendingEntry); + p._updateViewTitle(); + + const choices = parseChoices(question); + const isApproval = choices.length === 0 && isApprovalQuestion(question); + + // Wait for webview to be ready + if (!p._webviewReady) { + const maxWaitMs = 3000; + const pollIntervalMs = 50; + let waited = 0; + while (!p._webviewReady && waited < maxWaitMs) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + waited += pollIntervalMs; + } + } + + if (p._webviewReady && p._view) { + debugLog( + `[TaskSync] waitForUserResponse — posting toolCallPending to webview, id: ${toolCallId}, summary: ${summary ? summary.slice(0, 40) : "(none)"}`, + ); + p._view.webview.postMessage({ + type: "toolCallPending", + id: toolCallId, + prompt: question, + isApproval, + choices: choices.length > 0 ? choices : undefined, + summary: summary || undefined, + } satisfies ToWebviewMessage); + p.playNotificationSound(); + } else { + debugLog( + `[TaskSync] waitForUserResponse — webview not ready, deferring toolCallPending message for id: ${toolCallId}`, + ); + p._pendingToolCallMessage = { + id: toolCallId, + prompt: question, + summary: summary || undefined, + }; + } + + debugLog( + `[TaskSync] waitForUserResponse — broadcasting toolCallPending to remote, id: ${toolCallId}`, + ); + // Broadcast to remote clients + p._remoteServer?.broadcast("toolCallPending", { + id: toolCallId, + prompt: question, + isApproval, + timestamp: Date.now(), + summary: summary || undefined, + choices: + choices.length > 0 + ? choices.map( + (c: { label: string; value: string; shortLabel?: string }) => ({ + label: c.label, + value: c.value, + shortLabel: c.shortLabel, + }), + ) + : undefined, + }); + + p._updateCurrentSessionUI(); + + debugLog( + `[TaskSync] waitForUserResponse — waiting for user input, toolCallId: ${toolCallId}`, + ); + return new Promise((resolve) => { + p._pendingRequests.set(toolCallId, resolve); + startResponseTimeoutTimer(p, toolCallId); + }); +} + +/** + * Start response timeout timer for a pending tool call. + */ +export function startResponseTimeoutTimer(p: P, toolCallId: string): void { + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + const timeoutMinutes = settingsH.readResponseTimeoutMinutes(); + if (timeoutMinutes <= 0) { + debugLog( + `[TaskSync] startResponseTimeoutTimer — timeout disabled (${timeoutMinutes} min), no timer set`, + ); + return; + } + + const timeoutMs = timeoutMinutes * 60 * 1000; + debugLog( + `[TaskSync] startResponseTimeoutTimer — setting ${timeoutMinutes} min timer for toolCallId: ${toolCallId}`, + ); + + p._responseTimeoutTimer = setTimeout(() => { + handleResponseTimeout(p, toolCallId); + }, timeoutMs); +} + +/** + * Handle response timeout — auto-respond after user idle. + */ +export async function handleResponseTimeout( + p: P, + toolCallId: string, +): Promise { + debugLog( + `[TaskSync] handleResponseTimeout — toolCallId: ${toolCallId}, currentToolCallId: ${p._currentToolCallId}`, + ); + p._responseTimeoutTimer = null; + + if ( + p._currentToolCallId !== toolCallId || + !p._pendingRequests.has(toolCallId) + ) { + debugLog("[TaskSync] handleResponseTimeout — stale timeout, ignoring"); + return; + } + + await p._applyHumanLikeDelay("Timeout"); + + if ( + p._currentToolCallId !== toolCallId || + !p._pendingRequests.has(toolCallId) + ) { + return; + } + + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const timeoutMinutes = settingsH.readResponseTimeoutMinutes(config); + const maxConsecutive = config.get( + "maxConsecutiveAutoResponses", + DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, + ); + + p._consecutiveAutoResponses++; + let responseText: string; + let isTermination = false; + + if (p._consecutiveAutoResponses > maxConsecutive) { + responseText = p._SESSION_TERMINATION_TEXT; + isTermination = true; + debugLog( + `[TaskSync] handleResponseTimeout — auto-response limit reached (${p._consecutiveAutoResponses}/${maxConsecutive}), terminating session`, + ); + vscode.window.showWarningMessage( + `TaskSync: Auto-response limit (${maxConsecutive}) reached. Session terminated after ${timeoutMinutes} min idle.`, + ); + } else if (p._autopilotEnabled) { + responseText = settingsH.normalizeAutopilotText(p, p._autopilotText); + debugLog( + `[TaskSync] handleResponseTimeout — autopilot auto-responding with: "${responseText.slice(0, 60)}"`, + ); + vscode.window.showInformationMessage( + `TaskSync: Auto-responded after ${timeoutMinutes} min idle. (${p._consecutiveAutoResponses}/${maxConsecutive})`, + ); + } else { + responseText = p._SESSION_TERMINATION_TEXT; + isTermination = true; + debugLog( + `[TaskSync] handleResponseTimeout — no autopilot, terminating session after ${timeoutMinutes} min idle`, + ); + vscode.window.showInformationMessage( + `TaskSync: Session terminated after ${timeoutMinutes} min idle.`, + ); + } + + const resolve = p._pendingRequests.get(toolCallId); + if (resolve) { + const pendingEntry = p._currentSessionCallsMap.get(toolCallId); + if (pendingEntry && pendingEntry.status === "pending") { + pendingEntry.response = responseText; + pendingEntry.status = "completed"; + pendingEntry.timestamp = Date.now(); + pendingEntry.isFromQueue = false; + + p._view?.webview.postMessage({ + type: "toolCallCompleted", + entry: pendingEntry, + sessionTerminated: isTermination, + } satisfies ToWebviewMessage); + + // Broadcast timeout auto-response to remote clients + broadcastToolCallCompleted(p, pendingEntry, isTermination); + } + + p._updateCurrentSessionUI(); + resolve({ + value: responseText, + queue: hasQueuedItems(p), + attachments: [], + } as UserResponseResult); + p._pendingRequests.delete(toolCallId); + p._currentToolCallId = null; + p._aiTurnActive = true; // AI is now processing the timeout auto-response + debugLog( + `[TaskSync] handleResponseTimeout — resolved with: "${responseText.slice(0, 60)}", isTermination: ${isTermination}, aiTurnActive: true`, + ); + + if (isTermination) { + markSessionTerminated(p); + } + } +} diff --git a/tasksync-chat/src/webview/webviewProvider.ts b/tasksync-chat/src/webview/webviewProvider.ts index 4a912eb..d32a1d9 100644 --- a/tasksync-chat/src/webview/webviewProvider.ts +++ b/tasksync-chat/src/webview/webviewProvider.ts @@ -1,3280 +1,546 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { FILE_EXCLUSION_PATTERNS, FILE_SEARCH_EXCLUSION_PATTERNS, formatExcludePattern } from '../constants/fileExclusions'; -import { ContextManager, ContextReferenceType } from '../context'; - -// Queued prompt interface -export interface QueuedPrompt { - id: string; - prompt: string; - attachments?: AttachmentInfo[]; // Optional attachments (images, files) included with the prompt -} - -// Attachment info -export interface AttachmentInfo { - id: string; - name: string; - uri: string; - isTemporary?: boolean; - isFolder?: boolean; - isTextReference?: boolean; -} - -// File search result (also used for context items like #terminal, #problems) -export interface FileSearchResult { - name: string; - path: string; - uri: string; - icon: string; - isFolder?: boolean; - isContext?: boolean; // true for #terminal, #problems context items -} - -// User response result -export interface UserResponseResult { - value: string; - queue: boolean; - attachments: AttachmentInfo[]; - cancelled?: boolean; // Indicates if the request was superseded by a new one -} - -// Tool call history entry -export interface ToolCallEntry { - id: string; - prompt: string; - response: string; - timestamp: number; - isFromQueue: boolean; - status: 'pending' | 'completed' | 'cancelled'; - attachments?: AttachmentInfo[]; -} - -// Parsed choice from question -export interface ParsedChoice { - label: string; // Display text (e.g., "1" or "Test functionality") - value: string; // Response value to send (e.g., "1" or full text) - shortLabel?: string; // Short version for button (e.g., "1" for numbered) -} - -// Reusable prompt interface -export interface ReusablePrompt { - id: string; - name: string; // Short name for /slash command (e.g., "fix", "test", "refactor") - prompt: string; // Full prompt text -} - -// Message types -type ToWebviewMessage = - | { type: 'updateQueue'; queue: QueuedPrompt[]; enabled: boolean } - | { type: 'toolCallPending'; id: string; prompt: string; isApprovalQuestion: boolean; choices?: ParsedChoice[] } - | { type: 'toolCallCompleted'; entry: ToolCallEntry; sessionTerminated?: boolean } - | { type: 'updateCurrentSession'; history: ToolCallEntry[] } - | { type: 'updatePersistedHistory'; history: ToolCallEntry[] } - | { type: 'fileSearchResults'; files: FileSearchResult[] } - | { type: 'updateAttachments'; attachments: AttachmentInfo[] } - | { type: 'imageSaved'; attachment: AttachmentInfo } - | { type: 'openSettingsModal' } - | { type: 'updateSettings'; soundEnabled: boolean; interactiveApprovalEnabled: boolean; autopilotEnabled: boolean; autopilotText: string; autopilotPrompts: string[]; reusablePrompts: ReusablePrompt[]; responseTimeout: number; sessionWarningHours: number; maxConsecutiveAutoResponses: number; humanLikeDelayEnabled: boolean; humanLikeDelayMin: number; humanLikeDelayMax: number; sendWithCtrlEnter: boolean } - | { type: 'slashCommandResults'; prompts: ReusablePrompt[] } - | { type: 'playNotificationSound' } - | { type: 'contextSearchResults'; suggestions: Array<{ type: string; label: string; description: string; detail: string }> } - | { type: 'contextReferenceAdded'; reference: { id: string; type: string; label: string; content: string } } - | { type: 'clear' } - | { type: 'updateSessionTimer'; startTime: number | null; frozenElapsed: number | null } - | { type: 'triggerSendFromShortcut' }; - -type FromWebviewMessage = - | { type: 'submit'; value: string; attachments: AttachmentInfo[] } - | { type: 'addQueuePrompt'; prompt: string; id: string; attachments?: AttachmentInfo[] } - | { type: 'removeQueuePrompt'; promptId: string } - | { type: 'editQueuePrompt'; promptId: string; newPrompt: string } - | { type: 'reorderQueue'; fromIndex: number; toIndex: number } - | { type: 'toggleQueue'; enabled: boolean } - | { type: 'clearQueue' } - | { type: 'addAttachment' } - | { type: 'removeAttachment'; attachmentId: string } - | { type: 'removeHistoryItem'; callId: string } - | { type: 'clearPersistedHistory' } - | { type: 'openHistoryModal' } - | { type: 'newSession' } - | { type: 'searchFiles'; query: string } - | { type: 'saveImage'; data: string; mimeType: string } - | { type: 'addFileReference'; file: FileSearchResult } - | { type: 'webviewReady' } - | { type: 'openSettingsModal' } - | { type: 'updateSoundSetting'; enabled: boolean } - | { type: 'updateInteractiveApprovalSetting'; enabled: boolean } - | { type: 'updateAutopilotSetting'; enabled: boolean } - | { type: 'updateAutopilotText'; text: string } - | { type: 'addAutopilotPrompt'; prompt: string } - | { type: 'editAutopilotPrompt'; index: number; prompt: string } - | { type: 'removeAutopilotPrompt'; index: number } - | { type: 'reorderAutopilotPrompts'; fromIndex: number; toIndex: number } - | { type: 'addReusablePrompt'; name: string; prompt: string } - | { type: 'editReusablePrompt'; id: string; name: string; prompt: string } - | { type: 'removeReusablePrompt'; id: string } - | { type: 'searchSlashCommands'; query: string } - | { type: 'openExternal'; url: string } - | { type: 'openFileLink'; target: string } - | { type: 'updateResponseTimeout'; value: number } - | { type: 'updateSessionWarningHours'; value: number } - | { type: 'updateMaxConsecutiveAutoResponses'; value: number } - | { type: 'updateHumanDelaySetting'; enabled: boolean } - | { type: 'updateHumanDelayMin'; value: number } - | { type: 'updateHumanDelayMax'; value: number } - | { type: 'updateSendWithCtrlEnterSetting'; enabled: boolean } - | { type: 'searchContext'; query: string } - | { type: 'selectContextReference'; contextType: string; options?: Record } - | { type: 'copyToClipboard'; text: string }; - - -export class TaskSyncWebviewProvider implements vscode.WebviewViewProvider, vscode.Disposable { - public static readonly viewType = 'taskSyncView'; - - private _view?: vscode.WebviewView; - private _pendingRequests: Map void> = new Map(); - - // Prompt queue state - private _promptQueue: QueuedPrompt[] = []; - private _queueEnabled: boolean = true; // Default to queue mode - - // Attachments state - private _attachments: AttachmentInfo[] = []; - - // Current session tool calls (memory only - not persisted during session) - private _currentSessionCalls: ToolCallEntry[] = []; - - // Persisted history from past sessions (loaded from disk) - private _persistedHistory: ToolCallEntry[] = []; - private _currentToolCallId: string | null = null; - - // Webview ready state - prevents race condition on first message - private _webviewReady: boolean = false; - private _pendingToolCallMessage: { id: string; prompt: string } | null = null; - - // Debounce timer for queue persistence - private _queueSaveTimer: ReturnType | null = null; - - private readonly _QUEUE_SAVE_DEBOUNCE_MS = 300; - - // Debounce timer for history persistence (async background saves) - private _historySaveTimer: ReturnType | null = null; - private readonly _HISTORY_SAVE_DEBOUNCE_MS = 2000; // 2 seconds debounce - private _historyDirty: boolean = false; // Track if history needs saving - - // Performance limits - private readonly _MAX_HISTORY_ENTRIES = 100; - private readonly _MAX_FILE_SEARCH_RESULTS = 500; - private readonly _MAX_QUEUE_PROMPT_LENGTH = 100000; // 100KB for queue prompts - private readonly _MAX_FOLDER_SEARCH_RESULTS = 1000; - private readonly _VIEW_OPEN_TIMEOUT_MS = 5000; - private readonly _VIEW_OPEN_POLL_INTERVAL_MS = 100; - private readonly _SHORT_QUESTION_THRESHOLD = 100; // chars for approval heuristic - - // File search cache with TTL - private _fileSearchCache: Map = new Map(); - private readonly _FILE_CACHE_TTL_MS = 5000; - - // Map for O(1) lookup of tool calls by ID (synced with _currentSessionCalls array) - private _currentSessionCallsMap: Map = new Map(); - - // Reusable prompts (loaded from VS Code settings) - private _reusablePrompts: ReusablePrompt[] = []; - - // Notification sound enabled (loaded from VS Code settings) - private _soundEnabled: boolean = true; - - // Interactive approval buttons enabled (loaded from VS Code settings) - private _interactiveApprovalEnabled: boolean = true; - - private readonly _AUTOPILOT_DEFAULT_TEXT = 'You are temporarily in autonomous mode and must now make your own decision. If another question arises, be sure to ask it, as autonomous mode is temporary.'; - private readonly _SESSION_TERMINATION_TEXT = 'Session terminated. Do not use askUser tool again.'; - - // Autopilot enabled (loaded from VS Code settings) - private _autopilotEnabled: boolean = false; - - // Autopilot text (legacy, kept for backward compatibility) - private _autopilotText: string = ''; - - // Autopilot prompts array (cycles through in order) - private _autopilotPrompts: string[] = []; - - // Current index in autopilot prompts cycle (resets on new session) - private _autopilotIndex: number = 0; - - // Human-like delay settings: adds random jitter before auto-responses. - // Simulates natural human reading/typing time for a more realistic workflow. - private _humanLikeDelayEnabled: boolean = true; - private _humanLikeDelayMin: number = 2; // seconds - private _humanLikeDelayMax: number = 6; // seconds - - // Session warning threshold (hours). 0 disables the warning. - private _sessionWarningHours: number = 2; - - // Allowed timeout values (minutes) shared across config reads/writes and UI sync. - private readonly _RESPONSE_TIMEOUT_ALLOWED_MINUTES = new Set([ - 0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 150, 180, 210, 240 - ]); - private readonly _RESPONSE_TIMEOUT_DEFAULT_MINUTES = 60; - - // Send behavior: false => Enter, true => Ctrl/Cmd+Enter - private _sendWithCtrlEnter: boolean = false; - - // Flag to prevent config reload during our own updates (avoids race condition) - private _isUpdatingConfig: boolean = false; - - // Disposables to clean up - private _disposables: vscode.Disposable[] = []; - - // Context manager for #terminal, #problems references - private readonly _contextManager: ContextManager; - - // Response timeout tracking - private _responseTimeoutTimer: ReturnType | null = null; - private _consecutiveAutoResponses: number = 0; - - // Session timer (resets on new session) - private _sessionStartTime: number | null = null; // timestamp when first tool call occurred - private _sessionFrozenElapsed: number | null = null; // frozen elapsed ms when session terminated - private _sessionTimerInterval: ReturnType | null = null; - // Flag indicating the session was terminated (next tool call auto-starts new session) - private _sessionTerminated: boolean = false; - // Flag to ensure the 2-hour session warning is only shown once per session - private _sessionWarningShown: boolean = false; - - constructor( - private readonly _extensionUri: vscode.Uri, - private readonly _context: vscode.ExtensionContext, - contextManager: ContextManager - ) { - this._contextManager = contextManager; - // Load both queue and history async to not block activation - this._loadQueueFromDiskAsync().catch(err => { - console.error('Failed to load queue:', err); - }); - this._loadPersistedHistoryFromDiskAsync().catch(err => { - console.error('Failed to load history:', err); - }); - // Load settings (sync - fast operation) - this._loadSettings(); - - // Listen for settings changes - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(e => { - // Skip reload if we're the ones updating config (prevents race condition) - if (this._isUpdatingConfig) { - return; - } - if (e.affectsConfiguration('tasksync.notificationSound') || - e.affectsConfiguration('tasksync.interactiveApproval') || - e.affectsConfiguration('tasksync.autopilot') || - e.affectsConfiguration('tasksync.autopilotText') || - e.affectsConfiguration('tasksync.autopilotPrompts') || - e.affectsConfiguration('tasksync.autoAnswer') || - e.affectsConfiguration('tasksync.autoAnswerText') || - e.affectsConfiguration('tasksync.reusablePrompts') || - e.affectsConfiguration('tasksync.responseTimeout') || - e.affectsConfiguration('tasksync.sessionWarningHours') || - e.affectsConfiguration('tasksync.maxConsecutiveAutoResponses') || - e.affectsConfiguration('tasksync.humanLikeDelay') || - e.affectsConfiguration('tasksync.humanLikeDelayMin') || - e.affectsConfiguration('tasksync.humanLikeDelayMax') || - e.affectsConfiguration('tasksync.sendWithCtrlEnter')) { - this._loadSettings(); - this._updateSettingsUI(); - } - }) - ); - } - - /** - * Save current tool call history to persisted history (called on deactivate) - * Uses synchronous save because deactivate cannot await async operations - */ - public saveCurrentSessionToHistory(): void { - // Cancel any pending debounced saves - if (this._historySaveTimer) { - clearTimeout(this._historySaveTimer); - this._historySaveTimer = null; - } - - // Only save completed calls from current session - const completedCalls = this._currentSessionCalls.filter(tc => tc.status === 'completed'); - if (completedCalls.length > 0) { - // Prepend current session calls to persisted history, enforce max limit - this._persistedHistory = [...completedCalls, ...this._persistedHistory].slice(0, this._MAX_HISTORY_ENTRIES); - this._historyDirty = true; - } - - // Force sync save on deactivation (async operations can't complete in deactivate) - this._savePersistedHistoryToDiskSync(); - } - - /** - * Open history modal (called from view title bar button) - */ - public openHistoryModal(): void { - this._view?.webview.postMessage({ type: 'openHistoryModal' }); - this._updatePersistedHistoryUI(); - } - - /** - * Open settings modal (called from view title bar button) - */ - public openSettingsModal(): void { - this._view?.webview.postMessage({ type: 'openSettingsModal' } as ToWebviewMessage); - this._updateSettingsUI(); - } - - /** - * Trigger send action in webview from a VS Code command/keybinding. - */ - public triggerSendFromShortcut(): void { - this._view?.webview.postMessage({ type: 'triggerSendFromShortcut' } as ToWebviewMessage); - } - - /** - * Start a new session: save current session to history, then clear - * Called from view title bar button or webview "Start new session" button - */ - public startNewSession(): void { - // Cancel any in-flight pending requests - if (this._responseTimeoutTimer) { - clearTimeout(this._responseTimeoutTimer); - this._responseTimeoutTimer = null; - } - if (this._currentToolCallId) { - const resolve = this._pendingRequests.get(this._currentToolCallId); - if (resolve) { - resolve({ value: '', queue: false, attachments: [] }); - } - this._pendingRequests.delete(this._currentToolCallId); - this._currentToolCallId = null; - } - this._consecutiveAutoResponses = 0; - this._autopilotIndex = 0; // Reset autopilot prompts cycle - - // Save current completed entries to persisted history - this.saveCurrentSessionToHistory(); - - // Clean up temp images from current session - this._cleanupTempImagesFromEntries(this._currentSessionCalls); - - // Clear all entries - this._currentSessionCalls = []; - this._currentSessionCallsMap.clear(); - - // Reset session timer and termination flag - this._sessionStartTime = null; - this._sessionFrozenElapsed = null; - this._stopSessionTimerInterval(); - this._sessionTerminated = false; - this._sessionWarningShown = false; - this._updateViewTitle(); - - this._updateCurrentSessionUI(); - this._updatePersistedHistoryUI(); - - // Show welcome section again - this._view?.webview.postMessage({ type: 'clear' } as ToWebviewMessage); - } - - /** - * Play notification sound (called when ask_user tool is triggered) - * Works even when webview is not visible by using system sound - */ - public playNotificationSound(): void { - if (this._soundEnabled) { - // Play system sound from extension host (works even when webview is hidden) - this._playSystemSound(); - - // Also try webview audio if visible (better quality) - this._view?.webview.postMessage({ type: 'playNotificationSound' } as ToWebviewMessage); - } - } - - /** - * Play system sound using OS-native methods - * Works even when webview is minimized or hidden - */ - - /** - * Format elapsed milliseconds as human-readable (e.g., 5m 32s, 1h 5m 32s) - */ - private _formatElapsed(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; - } - - /** - * Generate a random delay (jitter) between min and max seconds. - * - * Random delays simulate natural human pacing — the time it takes - * to read a message and type a response. Fixed delays create - * unnatural patterns; jitter produces realistic timing variation. - * - * @returns Random delay in milliseconds, or 0 if disabled - */ - private _getHumanLikeDelayMs(): number { - if (!this._humanLikeDelayEnabled) { - return 0; - } - const minMs = this._humanLikeDelayMin * 1000; - const maxMs = this._humanLikeDelayMax * 1000; - // Uniform random distribution across the range - return Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; - } - - /** - * Wait a random duration before sending an automated response. - * - * Called before Autopilot, Queue, and Timeout auto-responses. The delay - * varies randomly each time (jitter), simulating natural human pacing - * rather than instant machine-speed responses. - * - * @param label Shown in status bar during wait (e.g., "Autopilot") - */ - private async _applyHumanLikeDelay(label?: string): Promise { - const delayMs = this._getHumanLikeDelayMs(); - if (delayMs > 0) { - const delaySec = (delayMs / 1000).toFixed(1); - if (label) { - vscode.window.setStatusBarMessage(`TaskSync: ${label} responding in ${delaySec}s...`, delayMs); - } - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - - private static readonly _TIMER_TOOLTIP = 'It is advisable to start a new session and use another premium request prompt after 2-4h or 50 tool calls'; - - /** - * Update the view title and webview with current session timer state - */ - private _updateViewTitle(): void { - if (this._view) { - const callCount = this._currentSessionCalls.length; - if (this._sessionFrozenElapsed !== null) { - this._view.title = this._formatElapsed(this._sessionFrozenElapsed); - this._view.badge = callCount > 0 ? { value: callCount, tooltip: TaskSyncWebviewProvider._TIMER_TOOLTIP } : undefined; - } else if (this._sessionStartTime !== null) { - this._view.title = this._formatElapsed(Date.now() - this._sessionStartTime); - this._view.badge = callCount > 0 ? { value: callCount, tooltip: TaskSyncWebviewProvider._TIMER_TOOLTIP } : undefined; - } else { - this._view.title = undefined; - this._view.description = undefined; - this._view.badge = undefined; - } - this._view.webview.postMessage({ - type: 'updateSessionTimer', - startTime: this._sessionStartTime, - frozenElapsed: this._sessionFrozenElapsed - } as ToWebviewMessage); - } - } - - /** - * Start the session timer interval that updates the view description every second - */ - private _startSessionTimerInterval(): void { - if (this._sessionTimerInterval) return; // Already running - this._sessionTimerInterval = setInterval(() => { - if (this._sessionStartTime !== null && this._sessionFrozenElapsed === null) { - const elapsed = Date.now() - this._sessionStartTime; - if (this._view) { - this._view.title = this._formatElapsed(elapsed); - } - // Warn once when a long-running session crosses the configured threshold. - const warningThresholdMs = this._sessionWarningHours * 60 * 60 * 1000; - if (this._sessionWarningHours > 0 && !this._sessionWarningShown && elapsed >= warningThresholdMs) { - this._sessionWarningShown = true; - const callCount = this._currentSessionCalls.length; - const hoursLabel = this._sessionWarningHours === 1 ? 'hour' : 'hours'; - vscode.window.showWarningMessage( - `Your session has been running for over ${this._sessionWarningHours} ${hoursLabel} (${callCount} tool calls). Consider starting a new session to maintain quality.`, - 'New Session', - 'Dismiss' - ).then(action => { - if (action === 'New Session') { - this.startNewSession(); - } - }); - } - } - }, 1000); - } - - /** - * Stop the session timer interval - */ - private _stopSessionTimerInterval(): void { - if (this._sessionTimerInterval) { - clearInterval(this._sessionTimerInterval); - this._sessionTimerInterval = null; - } - } - private _playSystemSound(): void { - const { exec } = require('child_process'); - const platform = process.platform; - - try { - if (platform === 'win32') { - // Windows: Use PowerShell to play system exclamation sound - exec('[System.Media.SystemSounds]::Exclamation.Play()', { shell: 'powershell.exe' }); - } else if (platform === 'darwin') { - // macOS: Use afplay with system sound - exec('afplay /System/Library/Sounds/Tink.aiff 2>/dev/null || printf "\\a"'); - } else { - // Linux: Try multiple methods - exec('paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a"'); - } - } catch (e) { - // Sound playing failed - not critical - } - } - - /** - * Load settings from VS Code configuration - */ - private _getAutopilotDefaultText(config?: vscode.WorkspaceConfiguration): string { - const settings = config ?? vscode.workspace.getConfiguration('tasksync'); - const inspected = settings.inspect('autopilotText'); - const defaultValue = typeof inspected?.defaultValue === 'string' ? inspected.defaultValue : ''; - return defaultValue.trim().length > 0 ? defaultValue : this._AUTOPILOT_DEFAULT_TEXT; - } - - private _normalizeAutopilotText(text: string, config?: vscode.WorkspaceConfiguration): string { - const defaultAutopilotText = this._getAutopilotDefaultText(config); - return text.trim().length > 0 ? text : defaultAutopilotText; - } - - private _normalizeResponseTimeout(value: unknown): number { - let parsedValue: number; - - if (typeof value === 'number') { - parsedValue = value; - } else if (typeof value === 'string') { - const normalizedValue = value.trim(); - if (normalizedValue.length === 0) { - return this._RESPONSE_TIMEOUT_DEFAULT_MINUTES; - } - parsedValue = Number(normalizedValue); - } else { - return this._RESPONSE_TIMEOUT_DEFAULT_MINUTES; - } - - if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue)) { - return this._RESPONSE_TIMEOUT_DEFAULT_MINUTES; - } - if (!this._RESPONSE_TIMEOUT_ALLOWED_MINUTES.has(parsedValue)) { - return this._RESPONSE_TIMEOUT_DEFAULT_MINUTES; - } - return parsedValue; - } - - private _readResponseTimeoutMinutes(config?: vscode.WorkspaceConfiguration): number { - const settings = config ?? vscode.workspace.getConfiguration('tasksync'); - const configuredTimeout = settings.get('responseTimeout', String(this._RESPONSE_TIMEOUT_DEFAULT_MINUTES)); - return this._normalizeResponseTimeout(configuredTimeout); - } - - private _loadSettings(): void { - const config = vscode.workspace.getConfiguration('tasksync'); - this._soundEnabled = config.get('notificationSound', true); - this._interactiveApprovalEnabled = config.get('interactiveApproval', true); - - // Backward-compatible migration: read old 'autoAnswer'/'autoAnswerText' keys - // if the new 'autopilot'/'autopilotText' keys have not been explicitly set by the user. - const inspectedAutopilot = config.inspect('autopilot'); - const hasNewAutopilotKey = inspectedAutopilot?.globalValue !== undefined - || inspectedAutopilot?.workspaceValue !== undefined - || inspectedAutopilot?.workspaceFolderValue !== undefined; - - if (!hasNewAutopilotKey) { - const oldVal = config.inspect('autoAnswer'); - const hasOldKey = oldVal?.globalValue !== undefined - || oldVal?.workspaceValue !== undefined - || oldVal?.workspaceFolderValue !== undefined; - if (hasOldKey) { - this._autopilotEnabled = config.get('autoAnswer', false); - } else { - this._autopilotEnabled = false; - } - } else { - this._autopilotEnabled = config.get('autopilot', false); - } - - const defaultAutopilotText = this._getAutopilotDefaultText(config); - - const inspectedAutopilotText = config.inspect('autopilotText'); - const hasNewAutopilotTextKey = inspectedAutopilotText?.globalValue !== undefined - || inspectedAutopilotText?.workspaceValue !== undefined - || inspectedAutopilotText?.workspaceFolderValue !== undefined; - - if (!hasNewAutopilotTextKey) { - const oldTextVal = config.inspect('autoAnswerText'); - const hasOldTextKey = oldTextVal?.globalValue !== undefined - || oldTextVal?.workspaceValue !== undefined - || oldTextVal?.workspaceFolderValue !== undefined; - if (hasOldTextKey) { - const oldText = config.get('autoAnswerText', defaultAutopilotText); - this._autopilotText = this._normalizeAutopilotText(oldText, config); - } else { - this._autopilotText = defaultAutopilotText; - } - } else { - const configuredAutopilotText = config.get('autopilotText', defaultAutopilotText); - this._autopilotText = this._normalizeAutopilotText(configuredAutopilotText, config); - } - - // Load autopilot prompts array (with fallback to autopilotText for migration) - const savedAutopilotPrompts = config.get('autopilotPrompts', []); - if (savedAutopilotPrompts.length > 0) { - // Use the configured prompts array, filtering out empty strings - this._autopilotPrompts = savedAutopilotPrompts.filter(p => p.trim().length > 0); - } else if (this._autopilotText && this._autopilotText !== defaultAutopilotText) { - // Migration: use existing autopilotText as the single prompt - this._autopilotPrompts = [this._autopilotText]; - } else { - // Default: empty array (will fall back to default text in cycling logic) - this._autopilotPrompts = []; - } - - // Load reusable prompts from settings - const savedPrompts = config.get>('reusablePrompts', []); - this._reusablePrompts = savedPrompts.map((p, index) => ({ - id: `rp_${index}_${Date.now()}`, - name: p.name, - prompt: p.prompt - })); - - // Load human-like delay settings - this._humanLikeDelayEnabled = config.get('humanLikeDelay', true); - this._humanLikeDelayMin = config.get('humanLikeDelayMin', 2); - this._humanLikeDelayMax = config.get('humanLikeDelayMax', 6); - const configuredWarningHours = config.get('sessionWarningHours', 2); - this._sessionWarningHours = Number.isFinite(configuredWarningHours) - ? Math.min(8, Math.max(0, Math.floor(configuredWarningHours))) - : 2; - this._sendWithCtrlEnter = config.get('sendWithCtrlEnter', false); - // Ensure min <= max - if (this._humanLikeDelayMin > this._humanLikeDelayMax) { - this._humanLikeDelayMin = this._humanLikeDelayMax; - } - } - - /** - * Save reusable prompts to VS Code configuration - */ - private async _saveReusablePrompts(): Promise { - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - const promptsToSave = this._reusablePrompts.map(p => ({ - name: p.name, - prompt: p.prompt - })); - await config.update('reusablePrompts', promptsToSave, vscode.ConfigurationTarget.Global); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Update settings UI in webview - */ - private _updateSettingsUI(): void { - const config = vscode.workspace.getConfiguration('tasksync'); - const responseTimeout = this._readResponseTimeoutMinutes(config); - const maxConsecutiveAutoResponses = config.get('maxConsecutiveAutoResponses', 5); - - this._view?.webview.postMessage({ - type: 'updateSettings', - soundEnabled: this._soundEnabled, - interactiveApprovalEnabled: this._interactiveApprovalEnabled, - autopilotEnabled: this._autopilotEnabled, - autopilotText: this._autopilotText, - autopilotPrompts: this._autopilotPrompts, - reusablePrompts: this._reusablePrompts, - responseTimeout: responseTimeout, - sessionWarningHours: this._sessionWarningHours, - maxConsecutiveAutoResponses: maxConsecutiveAutoResponses, - humanLikeDelayEnabled: this._humanLikeDelayEnabled, - humanLikeDelayMin: this._humanLikeDelayMin, - humanLikeDelayMax: this._humanLikeDelayMax, - sendWithCtrlEnter: this._sendWithCtrlEnter - } as ToWebviewMessage); - } - - /** - * Clean up resources when the provider is disposed - */ - public dispose(): void { - // Save session history BEFORE clearing arrays - // This ensures tool calls are persisted when VS Code reloads - this.saveCurrentSessionToHistory(); - - // Clear debounce timer - if (this._queueSaveTimer) { - clearTimeout(this._queueSaveTimer); - this._queueSaveTimer = null; - } - - // Clear file search cache - this._fileSearchCache.clear(); - - // Clear session calls map (O(1) lookup cache) - this._currentSessionCallsMap.clear(); - - // Clear pending requests (reject any waiting promises) - this._pendingRequests.clear(); - - // Clear response timeout timer - if (this._responseTimeoutTimer) { - clearTimeout(this._responseTimeoutTimer); - this._responseTimeoutTimer = null; - } - - // Clear session timer interval - this._stopSessionTimerInterval(); - - // Clean up temp images from current session before clearing - this._cleanupTempImagesFromEntries(this._currentSessionCalls); - - // Clear session data - this._currentSessionCalls = []; - this._attachments = []; - - // Dispose all registered disposables - this._disposables.forEach(d => d.dispose()); - this._disposables = []; - - this._view = undefined; - } - - public resolveWebviewView( - webviewView: vscode.WebviewView, - context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ) { - this._view = webviewView; - this._webviewReady = false; // Reset ready state when view is resolved - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [this._extensionUri] - }; - - webviewView.webview.html = this._getHtmlContent(webviewView.webview); - - // Restore session timer display if timer is already running - if (this._sessionStartTime !== null || this._sessionFrozenElapsed !== null) { - this._updateViewTitle(); - if (this._sessionStartTime !== null && this._sessionFrozenElapsed === null) { - this._startSessionTimerInterval(); - } - } - - // Register message handler (disposable is tracked via this._disposables) - webviewView.webview.onDidReceiveMessage( - (message: FromWebviewMessage) => this._handleWebviewMessage(message), - undefined, - this._disposables - ); - - // Clean up when webview is disposed - webviewView.onDidDispose(() => { - this._webviewReady = false; - this._view = undefined; - // Clear file search cache when view is hidden - this._fileSearchCache.clear(); - // Save current session to persisted history when view is disposed - this.saveCurrentSessionToHistory(); - }, null, this._disposables); - - // Save history when webview visibility changes (backup for reload) - webviewView.onDidChangeVisibility(() => { - if (!webviewView.visible) { - // Save current session when switching away - this.saveCurrentSessionToHistory(); - } - }, null, this._disposables); - - // Don't send initial state here - wait for webviewReady message - // This prevents race condition where messages are sent before JS is initialized - } - - /** - * Wait for user response - */ - private _cancelSupersededPendingRequest(): void { - if (!this._currentToolCallId || !this._pendingRequests.has(this._currentToolCallId)) { - return; - } - - // Cancel response timeout timer for superseded request - if (this._responseTimeoutTimer) { - clearTimeout(this._responseTimeoutTimer); - this._responseTimeoutTimer = null; - } - - const oldToolCallId = this._currentToolCallId; - const oldResolve = this._pendingRequests.get(oldToolCallId); - if (oldResolve) { - // Resolve the orphaned promise with a cancellation indicator - oldResolve({ - value: '[CANCELLED: New request superseded this one]', - queue: this._queueEnabled && this._promptQueue.length > 0, - attachments: [], - cancelled: true - }); - this._pendingRequests.delete(oldToolCallId); - - // Update the old entry status to indicate it was superseded - const oldEntry = this._currentSessionCallsMap.get(oldToolCallId); - if (oldEntry && oldEntry.status === 'pending') { - oldEntry.status = 'cancelled'; - oldEntry.response = '[Superseded by new request]'; - this._updateCurrentSessionUI(); - } - console.warn(`[TaskSync] Previous request ${oldToolCallId} was superseded by new request`); - } - } - - public async waitForUserResponse(question: string): Promise { - // Auto-start new session if previous session was terminated - if (this._sessionTerminated) { - this.startNewSession(); - } - - // Start session timer on first tool call - if (this._sessionStartTime === null) { - this._sessionStartTime = Date.now(); - this._sessionFrozenElapsed = null; - this._startSessionTimerInterval(); - this._updateViewTitle(); - } - - if (this._autopilotEnabled && !(this._queueEnabled && this._promptQueue.length > 0)) { - // Race condition prevention: If there's already a pending request, cancel it - // This prevents orphaned promises when waitForUserResponse is called multiple times - this._cancelSupersededPendingRequest(); - - // Increment consecutive auto-response counter - this._consecutiveAutoResponses++; - - // Get max consecutive limit from config - const config = vscode.workspace.getConfiguration('tasksync'); - const maxConsecutive = config.get('maxConsecutiveAutoResponses', 5); - - // Check if limit reached BEFORE auto-responding - if (this._consecutiveAutoResponses > maxConsecutive) { - // Limit exceeded - disable autopilot and wait for timeout or user response - this._autopilotEnabled = false; - await config.update('autopilot', false, vscode.ConfigurationTarget.Workspace); - this._updateSettingsUI(); - vscode.window.showWarningMessage(`TaskSync: Auto-response limit (${maxConsecutive}) reached. Waiting for response or timeout.`); - // Fall through to pending request flow with timeout timer - } else { - // Still under limit - auto-respond with autopilot text - const toolCallId = `tc_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - this._currentToolCallId = toolCallId; - - // Random delay (jitter) simulates human reading/response time - await this._applyHumanLikeDelay('Autopilot'); - - // Re-check after delay: user may have disabled autopilot or responded manually - if (!this._autopilotEnabled || this._currentToolCallId !== toolCallId) { - // State changed during delay — fall through to normal pending request flow - } else { - // Get the next prompt from the cycling array (or fallback to default) - let effectiveText: string; - if (this._autopilotPrompts.length > 0) { - effectiveText = this._autopilotPrompts[this._autopilotIndex]; - this._autopilotIndex = (this._autopilotIndex + 1) % this._autopilotPrompts.length; - } else { - effectiveText = this._normalizeAutopilotText(this._autopilotText); - } - vscode.window.showInformationMessage(`TaskSync: Autopilot auto-responded. (${this._consecutiveAutoResponses}/${maxConsecutive})`); - - const entry: ToolCallEntry = { - id: toolCallId, - prompt: question, - response: effectiveText, - timestamp: Date.now(), - isFromQueue: false, - status: 'completed' - }; - this._currentSessionCalls.unshift(entry); - this._currentSessionCallsMap.set(entry.id, entry); - this._updateViewTitle(); - this._updateCurrentSessionUI(); - this._currentToolCallId = null; - - return { - value: effectiveText, - queue: this._queueEnabled && this._promptQueue.length > 0, - attachments: [] - }; - } - } - } - - // If view is not available, open the sidebar first - if (!this._view) { - // Open the TaskSync sidebar view - await vscode.commands.executeCommand('taskSyncView.focus'); - - // Wait for view to be resolved (up to configured timeout) - let waited = 0; - while (!this._view && waited < this._VIEW_OPEN_TIMEOUT_MS) { - await new Promise(resolve => setTimeout(resolve, this._VIEW_OPEN_POLL_INTERVAL_MS)); - waited += this._VIEW_OPEN_POLL_INTERVAL_MS; - } - - if (!this._view) { - console.error(`[TaskSync] Failed to open sidebar view after waiting ${this._VIEW_OPEN_TIMEOUT_MS}ms`); - throw new Error(`Failed to open TaskSync sidebar after ${this._VIEW_OPEN_TIMEOUT_MS}ms. The webview may not be properly initialized.`); - } - } - - // Race condition prevention: If there's already a pending request, cancel it - // This prevents orphaned promises when waitForUserResponse is called multiple times - this._cancelSupersededPendingRequest(); - - const toolCallId = `tc_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - this._currentToolCallId = toolCallId; - - // Check if queue is enabled and has prompts - auto-respond - if (this._queueEnabled && this._promptQueue.length > 0) { - const queuedPrompt = this._promptQueue.shift(); - if (queuedPrompt) { - this._saveQueueToDisk(); - this._updateQueueUI(); - - // Random delay (jitter) simulates human reading/response time - await this._applyHumanLikeDelay('Queue'); - - // Re-check after delay: user may have disabled queue or responded manually - if (!this._queueEnabled || this._currentToolCallId !== toolCallId) { - // State changed during delay — restore prompt to queue and fall through - this._promptQueue.unshift(queuedPrompt); - this._saveQueueToDisk(); - this._updateQueueUI(); - } else { - // Create completed tool call entry for queue response - const entry: ToolCallEntry = { - id: toolCallId, - prompt: question, - response: queuedPrompt.prompt, - timestamp: Date.now(), - isFromQueue: true, - status: 'completed' - }; - this._currentSessionCalls.unshift(entry); - this._currentSessionCallsMap.set(entry.id, entry); // Maintain O(1) lookup map - this._updateViewTitle(); - this._updateCurrentSessionUI(); - this._currentToolCallId = null; - - return { - value: queuedPrompt.prompt, - queue: this._queueEnabled && this._promptQueue.length > 0, - attachments: queuedPrompt.attachments || [] // Return stored attachments - }; - } - } - } - - this._view.show(true); - - // Add pending entry to current session (so we have the prompt when completing) - const pendingEntry: ToolCallEntry = { - id: toolCallId, - prompt: question, - response: '', - timestamp: Date.now(), - isFromQueue: false, - status: 'pending' - }; - this._currentSessionCalls.unshift(pendingEntry); - this._currentSessionCallsMap.set(toolCallId, pendingEntry); // O(1) lookup - this._updateViewTitle(); - - // Parse choices from question and determine if it's an approval question - const choices = this._parseChoices(question); - const isApproval = choices.length === 0 && this._isApprovalQuestion(question); - - // Wait for webview to be ready (JS initialized) before sending message - if (!this._webviewReady) { - // Wait for webview JS to initialize (up to 3 seconds) - const maxWaitMs = 3000; - const pollIntervalMs = 50; - let waited = 0; - while (!this._webviewReady && waited < maxWaitMs) { - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); - waited += pollIntervalMs; - } - } - - // Send pending tool call to webview - if (this._webviewReady && this._view) { - this._view.webview.postMessage({ - type: 'toolCallPending', - id: toolCallId, - prompt: question, - isApprovalQuestion: isApproval, - choices: choices.length > 0 ? choices : undefined - }); - // Play notification sound when AI triggers ask_user - this.playNotificationSound(); - } else { - // Fallback: queue the message (should rarely happen now) - this._pendingToolCallMessage = { id: toolCallId, prompt: question }; - } - this._updateCurrentSessionUI(); - - return new Promise((resolve) => { - this._pendingRequests.set(toolCallId, resolve); - // Start response timeout timer - this._startResponseTimeoutTimer(toolCallId); - }); - } - - /** - * Start response timeout timer for a pending tool call. - * When timer fires, auto-responds with autopilot text. - */ - private _startResponseTimeoutTimer(toolCallId: string): void { - // Clear any existing timer - if (this._responseTimeoutTimer) { - clearTimeout(this._responseTimeoutTimer); - this._responseTimeoutTimer = null; - } - - // Get timeout from config (in minutes) - const timeoutMinutes = this._readResponseTimeoutMinutes(); - - // If timeout is 0 or disabled, don't start a timer - if (timeoutMinutes <= 0) { - return; - } - - const timeoutMs = timeoutMinutes * 60 * 1000; - - this._responseTimeoutTimer = setTimeout(() => { - this._handleResponseTimeout(toolCallId); - }, timeoutMs); - } - - /** - * Handle response timeout - auto-respond after user idle. - * When autopilot is enabled, uses the autopilot text. - * When autopilot is disabled, uses a neutral timeout message that won't - * instruct the AI to enter autonomous mode. - */ - private async _handleResponseTimeout(toolCallId: string): Promise { - this._responseTimeoutTimer = null; - - // Check if this tool call is still pending - if (this._currentToolCallId !== toolCallId || !this._pendingRequests.has(toolCallId)) { - return; // Tool call was already handled - } - - // Random delay (jitter) simulates human reading/response time - await this._applyHumanLikeDelay('Timeout'); - - // Re-check after delay in case request was handled during wait - if (this._currentToolCallId !== toolCallId || !this._pendingRequests.has(toolCallId)) { - return; - } - - // Use autopilot text only if autopilot is enabled; otherwise use session termination message - const config = vscode.workspace.getConfiguration('tasksync'); - const timeoutMinutes = this._readResponseTimeoutMinutes(config); - const maxConsecutive = config.get('maxConsecutiveAutoResponses', 5); - - // Increment and enforce consecutive auto-response limit - this._consecutiveAutoResponses++; - let responseText: string; - let isTermination = false; - - if (this._consecutiveAutoResponses > maxConsecutive) { - // Limit exceeded - terminate session - responseText = this._SESSION_TERMINATION_TEXT; - isTermination = true; - vscode.window.showWarningMessage(`TaskSync: Auto-response limit (${maxConsecutive}) reached. Session terminated after ${timeoutMinutes} min idle.`); - } else if (this._autopilotEnabled) { - responseText = this._normalizeAutopilotText(this._autopilotText); - vscode.window.showInformationMessage(`TaskSync: Auto-responded after ${timeoutMinutes} min idle. (${this._consecutiveAutoResponses}/${maxConsecutive})`); - } else { - responseText = this._SESSION_TERMINATION_TEXT; - isTermination = true; - vscode.window.showInformationMessage(`TaskSync: Session terminated after ${timeoutMinutes} min idle.`); - } - - // Resolve the pending request - const resolve = this._pendingRequests.get(toolCallId); - if (resolve) { - // Update pending entry to completed - const pendingEntry = this._currentSessionCallsMap.get(toolCallId); - if (pendingEntry && pendingEntry.status === 'pending') { - pendingEntry.response = responseText; - pendingEntry.status = 'completed'; - pendingEntry.timestamp = Date.now(); - pendingEntry.isFromQueue = false; - - // Send toolCallCompleted to webview with structured termination flag - this._view?.webview.postMessage({ - type: 'toolCallCompleted', - entry: pendingEntry, - sessionTerminated: isTermination - } as ToWebviewMessage); - } - - this._updateCurrentSessionUI(); - resolve({ value: responseText, queue: this._queueEnabled && this._promptQueue.length > 0, attachments: [] }); - this._pendingRequests.delete(toolCallId); - this._currentToolCallId = null; - - // Mark session as terminated if termination text was sent - if (isTermination) { - this._sessionTerminated = true; - // Freeze the session timer - if (this._sessionStartTime !== null) { - this._sessionFrozenElapsed = Date.now() - this._sessionStartTime; - this._stopSessionTimerInterval(); - this._updateViewTitle(); - } - } - } - } - - /** - * Check if queue is enabled - */ - public isQueueEnabled(): boolean { - return this._queueEnabled; - } - - /** - * Handle messages from webview - */ - private _handleWebviewMessage(message: FromWebviewMessage): void { - switch (message.type) { - case 'submit': - this._handleSubmit(message.value, message.attachments || []); - break; - case 'addQueuePrompt': - this._handleAddQueuePrompt(message.prompt, message.id, message.attachments || []); - break; - case 'removeQueuePrompt': - this._handleRemoveQueuePrompt(message.promptId); - break; - case 'editQueuePrompt': - this._handleEditQueuePrompt(message.promptId, message.newPrompt); - break; - case 'reorderQueue': - this._handleReorderQueue(message.fromIndex, message.toIndex); - break; - case 'toggleQueue': - this._handleToggleQueue(message.enabled); - break; - case 'clearQueue': - this._handleClearQueue(); - break; - case 'addAttachment': - this._handleAddAttachment(); - break; - case 'removeAttachment': - this._handleRemoveAttachment(message.attachmentId); - break; - case 'removeHistoryItem': - this._handleRemoveHistoryItem(message.callId); - break; - case 'clearPersistedHistory': - this._handleClearPersistedHistory(); - break; - case 'openHistoryModal': - this._handleOpenHistoryModal(); - break; - case 'newSession': - this.startNewSession(); - break; - case 'searchFiles': - this._handleSearchFiles(message.query); - break; - case 'saveImage': - this._handleSaveImage(message.data, message.mimeType); - break; - case 'addFileReference': - this._handleAddFileReference(message.file); - break; - case 'webviewReady': - this._handleWebviewReady(); - break; - case 'openSettingsModal': - this._handleOpenSettingsModal(); - break; - case 'updateSoundSetting': - this._handleUpdateSoundSetting(message.enabled); - break; - case 'updateInteractiveApprovalSetting': - this._handleUpdateInteractiveApprovalSetting(message.enabled); - break; - case 'updateAutopilotSetting': - this._handleUpdateAutopilotSetting(message.enabled); - break; - case 'updateAutopilotText': - this._handleUpdateAutopilotText(message.text); - break; - case 'addAutopilotPrompt': - this._handleAddAutopilotPrompt(message.prompt); - break; - case 'editAutopilotPrompt': - this._handleEditAutopilotPrompt(message.index, message.prompt); - break; - case 'removeAutopilotPrompt': - this._handleRemoveAutopilotPrompt(message.index); - break; - case 'reorderAutopilotPrompts': - this._handleReorderAutopilotPrompts(message.fromIndex, message.toIndex); - break; - case 'addReusablePrompt': - this._handleAddReusablePrompt(message.name, message.prompt); - break; - case 'editReusablePrompt': - this._handleEditReusablePrompt(message.id, message.name, message.prompt); - break; - case 'removeReusablePrompt': - this._handleRemoveReusablePrompt(message.id); - break; - case 'searchSlashCommands': - this._handleSearchSlashCommands(message.query); - break; - case 'openExternal': - this._handleOpenExternalLink(message.url); - break; - case 'openFileLink': - void this._handleOpenFileLink(message.target); - break; - case 'updateResponseTimeout': - this._handleUpdateResponseTimeout(message.value); - break; - case 'updateSessionWarningHours': - this._handleUpdateSessionWarningHours(message.value); - break; - case 'updateMaxConsecutiveAutoResponses': - this._handleUpdateMaxConsecutiveAutoResponses(message.value); - break; - case 'updateHumanDelaySetting': - this._handleUpdateHumanDelaySetting(message.enabled); - break; - case 'updateHumanDelayMin': - this._handleUpdateHumanDelayMin(message.value); - break; - case 'updateHumanDelayMax': - this._handleUpdateHumanDelayMax(message.value); - break; - case 'updateSendWithCtrlEnterSetting': - this._handleUpdateSendWithCtrlEnterSetting(message.enabled); - break; - case 'searchContext': - this._handleSearchContext(message.query); - break; - case 'selectContextReference': - this._handleSelectContextReference(message.contextType, message.options); - break; - case 'copyToClipboard': - void this._handleCopyToClipboard(message.text); - break; - } - } - - /** - * Handle webview ready signal - send initial state and any pending messages - */ - private _handleWebviewReady(): void { - this._webviewReady = true; - - // Send settings - this._updateSettingsUI(); - // Send initial queue state and current session history - this._updateQueueUI(); - this._updateCurrentSessionUI(); - - // If there's a pending tool call message that was never sent, send it now - if (this._pendingToolCallMessage) { - const prompt = this._pendingToolCallMessage.prompt; - const choices = this._parseChoices(prompt); - const isApproval = choices.length === 0 && this._isApprovalQuestion(prompt); - this._view?.webview.postMessage({ - type: 'toolCallPending', - id: this._pendingToolCallMessage.id, - prompt: prompt, - isApprovalQuestion: isApproval, - choices: choices.length > 0 ? choices : undefined - }); - this._pendingToolCallMessage = null; - } - // If there's an active pending request (webview was hidden/recreated while waiting), - // re-send the pending tool call message so the user sees the question again - else if (this._currentToolCallId && this._pendingRequests.has(this._currentToolCallId)) { - // Find the pending entry to get the prompt - const pendingEntry = this._currentSessionCallsMap.get(this._currentToolCallId); - if (pendingEntry && pendingEntry.status === 'pending') { - const prompt = pendingEntry.prompt; - const choices = this._parseChoices(prompt); - const isApproval = choices.length === 0 && this._isApprovalQuestion(prompt); - this._view?.webview.postMessage({ - type: 'toolCallPending', - id: this._currentToolCallId, - prompt: prompt, - isApprovalQuestion: isApproval, - choices: choices.length > 0 ? choices : undefined - }); - } - } - } - - /** - * Handle submit from webview - */ - private _handleSubmit(value: string, attachments: AttachmentInfo[]): void { - // Cancel response timeout timer (user responded) - if (this._responseTimeoutTimer) { - clearTimeout(this._responseTimeoutTimer); - this._responseTimeoutTimer = null; - } - // Reset consecutive auto-responses counter on manual response - this._consecutiveAutoResponses = 0; - - if (this._pendingRequests.size > 0 && this._currentToolCallId) { - const resolve = this._pendingRequests.get(this._currentToolCallId); - if (resolve) { - // O(1) lookup using Map instead of O(n) findIndex - const pendingEntry = this._currentSessionCallsMap.get(this._currentToolCallId); - - let completedEntry: ToolCallEntry; - if (pendingEntry && pendingEntry.status === 'pending') { - // Update existing pending entry - pendingEntry.response = value; - pendingEntry.attachments = attachments; - pendingEntry.status = 'completed'; - pendingEntry.timestamp = Date.now(); - completedEntry = pendingEntry; - } else { - // Create new completed entry (shouldn't happen normally) - completedEntry = { - id: this._currentToolCallId, - prompt: 'Tool call', - response: value, - attachments: attachments, - timestamp: Date.now(), - isFromQueue: false, - status: 'completed' - }; - this._currentSessionCalls.unshift(completedEntry); - this._currentSessionCallsMap.set(completedEntry.id, completedEntry); - } - - // Detect session termination - const isTermination = value === this._SESSION_TERMINATION_TEXT; - - // Send toolCallCompleted to trigger "Working...." state in webview - this._view?.webview.postMessage({ - type: 'toolCallCompleted', - entry: completedEntry, - sessionTerminated: isTermination - } as ToWebviewMessage); - - this._updateCurrentSessionUI(); - resolve({ value, queue: this._queueEnabled && this._promptQueue.length > 0, attachments }); - this._pendingRequests.delete(this._currentToolCallId); - this._currentToolCallId = null; - - // Mark session as terminated if termination text was submitted - if (isTermination) { - this._sessionTerminated = true; - // Freeze the session timer - if (this._sessionStartTime !== null) { - this._sessionFrozenElapsed = Date.now() - this._sessionStartTime; - this._stopSessionTimerInterval(); - this._updateViewTitle(); - } - } - } else { - // No pending tool call - add message to queue for later use - if (value && value.trim()) { - const queuedPrompt: QueuedPrompt = { - id: `q_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, - prompt: value.trim() - }; - this._promptQueue.push(queuedPrompt); - // Auto-switch to queue mode so user sees their message went to queue - this._queueEnabled = true; - this._saveQueueToDisk(); - this._updateQueueUI(); - } - } - // NOTE: Temp images are NOT cleaned up here anymore. - // They are stored in the ToolCallEntry.attachments and will be cleaned up when: - // 1. clearCurrentSession() is called - // 2. dispose() is called (extension deactivation) - // This ensures images are available for the entire session duration. - - // Clear attachments after submit and sync with webview - this._attachments = []; - this._updateAttachmentsUI(); - } - } - - /** - * Clean up temporary image files from disk by URI list - */ - private _cleanupTempImagesByUri(uris: string[]): void { - for (const uri of uris) { - try { - const filePath = vscode.Uri.parse(uri).fsPath; - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } catch (error) { - console.error('[TaskSync] Failed to cleanup temp image:', error); - } - } - } - - /** - * Clean up temporary images from tool call entries - * Called when entries are removed from current session or on dispose - */ - private _cleanupTempImagesFromEntries(entries: ToolCallEntry[]): void { - const tempUris: string[] = []; - for (const entry of entries) { - if (entry.attachments) { - for (const att of entry.attachments) { - // Only clean up temporary attachments (pasted/dropped images) - if (att.isTemporary && att.uri) { - tempUris.push(att.uri); - } - } - } - } - if (tempUris.length > 0) { - this._cleanupTempImagesByUri(tempUris); - } - } - - /** - * Handle adding attachment via file picker - */ - private async _handleAddAttachment(): Promise { - // Use shared exclude pattern - const excludePattern = formatExcludePattern(FILE_EXCLUSION_PATTERNS); - const files = await vscode.workspace.findFiles('**/*', excludePattern, this._MAX_FOLDER_SEARCH_RESULTS); - - if (files.length === 0) { - vscode.window.showInformationMessage('No files found in workspace'); - return; - } - - const items: (vscode.QuickPickItem & { uri: vscode.Uri })[] = files.map(uri => { - const relativePath = vscode.workspace.asRelativePath(uri); - const fileName = path.basename(uri.fsPath); - return { - label: `$(file) ${fileName}`, - description: relativePath, - uri: uri - }; - }).sort((a, b) => a.label.localeCompare(b.label)); - - const selected = await vscode.window.showQuickPick(items, { - canPickMany: true, - placeHolder: 'Select files to attach', - matchOnDescription: true - }); - - if (selected && selected.length > 0) { - for (const item of selected) { - const labelMatch = item.label.match(/\$\([^)]+\)\s*(.+)/); - const cleanName = labelMatch ? labelMatch[1] : item.label; - const attachment: AttachmentInfo = { - id: `att_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, - name: cleanName, - uri: item.uri.toString() - }; - this._attachments.push(attachment); - } - this._updateAttachmentsUI(); - } - } - - /** - * Handle removing attachment - */ - private _handleRemoveAttachment(attachmentId: string): void { - this._attachments = this._attachments.filter(a => a.id !== attachmentId); - this._updateAttachmentsUI(); - } - - /** - * Handle file search for autocomplete (also includes #terminal, #problems context) - */ - private async _handleSearchFiles(query: string): Promise { - try { - const queryLower = query.toLowerCase(); - const cacheKey = queryLower || '__all__'; - - // Check cache first (TTL-based) - const cached = this._fileSearchCache.get(cacheKey); - if (cached && (Date.now() - cached.timestamp) < this._FILE_CACHE_TTL_MS) { - this._view?.webview.postMessage({ - type: 'fileSearchResults', - files: cached.results - } as ToWebviewMessage); - return; - } - - // First, get context suggestions (#terminal, #problems) - const contextResults: FileSearchResult[] = []; - - // Check if query matches "terminal" - if (!queryLower || 'terminal'.includes(queryLower)) { - const commands = this._contextManager.terminal.formatCommandListForAutocomplete(); - const description = commands.length > 0 - ? `${commands.length} recent commands` - : 'No commands yet'; - contextResults.push({ - name: 'terminal', - path: description, - uri: 'context://terminal', - icon: 'terminal', - isFolder: false, - isContext: true - }); - } - - // Check if query matches "problems" - if (!queryLower || 'problems'.includes(queryLower)) { - const problemsInfo = this._contextManager.problems.formatForAutocomplete(); - contextResults.push({ - name: 'problems', - path: problemsInfo.description, - uri: 'context://problems', - icon: 'error', - isFolder: false, - isContext: true - }); - } - - // Exclude common unwanted files/folders for cleaner search results - // Includes: package managers, virtual envs, build outputs, hidden/config files - const excludePattern = formatExcludePattern(FILE_SEARCH_EXCLUSION_PATTERNS); - // Reduced from 2000 to _MAX_FILE_SEARCH_RESULTS for better performance - const allFiles = await vscode.workspace.findFiles('**/*', excludePattern, this._MAX_FILE_SEARCH_RESULTS); - - const seenFolders = new Set(); - const folderResults: FileSearchResult[] = []; - - for (const uri of allFiles) { - const relativePath = vscode.workspace.asRelativePath(uri); - const dirPath = path.dirname(relativePath); - - if (dirPath && dirPath !== '.' && !seenFolders.has(dirPath)) { - seenFolders.add(dirPath); - const folderName = path.basename(dirPath); - - if (!queryLower || folderName.toLowerCase().includes(queryLower) || dirPath.toLowerCase().includes(queryLower)) { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri)?.uri ?? vscode.workspace.workspaceFolders![0].uri; - folderResults.push({ - name: folderName, - path: dirPath, - uri: vscode.Uri.joinPath(workspaceFolder, dirPath).toString(), - icon: 'folder', - isFolder: true - }); - } - } - } - - const fileResults: FileSearchResult[] = allFiles - .map(uri => { - const relativePath = vscode.workspace.asRelativePath(uri); - const fileName = path.basename(uri.fsPath); - return { - name: fileName, - path: relativePath, - uri: uri.toString(), - icon: this._getFileIcon(fileName), - isFolder: false - }; - }) - .filter(file => !queryLower || file.name.toLowerCase().includes(queryLower) || file.path.toLowerCase().includes(queryLower)); - - // Combine: context results first, then folders, then files - const fileAndFolderResults = [...folderResults, ...fileResults] - .sort((a, b) => { - if (a.isFolder && !b.isFolder) return -1; - if (!a.isFolder && b.isFolder) return 1; - const aExact = a.name.toLowerCase().startsWith(queryLower); - const bExact = b.name.toLowerCase().startsWith(queryLower); - if (aExact && !bExact) return -1; - if (!aExact && bExact) return 1; - return a.name.localeCompare(b.name); - }) - .slice(0, 48); // Leave room for context items - - // Context results go first, then files/folders - const allResults = [...contextResults, ...fileAndFolderResults]; - - // Cache results (don't cache context results as they're dynamic) - this._fileSearchCache.set(cacheKey, { results: fileAndFolderResults, timestamp: Date.now() }); - // Limit cache size to prevent memory bloat - if (this._fileSearchCache.size > 20) { - const firstKey = this._fileSearchCache.keys().next().value; - if (firstKey) this._fileSearchCache.delete(firstKey); - } - - this._view?.webview.postMessage({ - type: 'fileSearchResults', - files: allResults - } as ToWebviewMessage); - } catch (error) { - console.error('File search error:', error); - this._view?.webview.postMessage({ - type: 'fileSearchResults', - files: [] - } as ToWebviewMessage); - } - } - - /** - * Handle saving pasted/dropped image - */ - private async _handleSaveImage(dataUrl: string, mimeType: string): Promise { - const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; - - try { - const base64Match = dataUrl.match(/^data:[^;]+;base64,(.+)$/); - if (!base64Match) { - vscode.window.showWarningMessage('Invalid image format'); - return; - } - - const base64Data = base64Match[1]; - - // SECURITY FIX: Validate base64 size BEFORE decoding to prevent memory spike - // Base64 encoding increases size by ~33%, so decoded size ≈ base64Length * 0.75 - const estimatedSize = Math.ceil(base64Data.length * 0.75); - if (estimatedSize > MAX_IMAGE_SIZE_BYTES) { - const sizeMB = (estimatedSize / (1024 * 1024)).toFixed(2); - vscode.window.showWarningMessage(`Image too large (~${sizeMB}MB). Max 10MB.`); - return; - } - - const buffer = Buffer.from(base64Data, 'base64'); - - if (buffer.length > MAX_IMAGE_SIZE_BYTES) { - const sizeMB = (buffer.length / (1024 * 1024)).toFixed(2); - vscode.window.showWarningMessage(`Image too large (${sizeMB}MB). Max 10MB.`); - return; - } - - const validMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp']; - if (!validMimeTypes.includes(mimeType)) { - vscode.window.showWarningMessage(`Unsupported image type: ${mimeType}`); - return; - } - - const extMap: Record = { - 'image/png': '.png', - 'image/jpeg': '.jpg', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'image/bmp': '.bmp' - }; - const ext = extMap[mimeType] || '.png'; - - // Use storageUri if available (workspace-specific), otherwise fallback to globalStorageUri - const storageUri = this._context.storageUri || this._context.globalStorageUri; - if (!storageUri) { - throw new Error('VS Code extension storage URI not available. Cannot save temporary images without storage access.'); - } - - const tempDir = path.join(storageUri.fsPath, 'temp-images'); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - - const existingImages = this._attachments.filter(a => a.isTemporary).length; - let fileName = existingImages === 0 ? `image-pasted${ext}` : `image-pasted-${existingImages}${ext}`; - let filePath = path.join(tempDir, fileName); - - let counter = existingImages; - while (fs.existsSync(filePath)) { - counter++; - fileName = `image-pasted-${counter}${ext}`; - filePath = path.join(tempDir, fileName); - } - - fs.writeFileSync(filePath, buffer); - - const attachment: AttachmentInfo = { - id: `img_${Date.now()}`, - name: fileName, - uri: vscode.Uri.file(filePath).toString(), - isTemporary: true - }; - - this._attachments.push(attachment); - - this._view?.webview.postMessage({ - type: 'imageSaved', - attachment - } as ToWebviewMessage); - - this._updateAttachmentsUI(); - } catch (error) { - console.error('Failed to save image:', error); - vscode.window.showErrorMessage('Failed to save pasted image'); - } - } - - /** - * Handle adding file reference from autocomplete - */ - private _handleAddFileReference(file: FileSearchResult): void { - const attachment: AttachmentInfo = { - id: `${file.isFolder ? 'folder' : 'file'}_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, - name: file.name, - uri: file.uri, - isFolder: file.isFolder, - isTextReference: true - }; - this._attachments.push(attachment); - this._updateAttachmentsUI(); - } - - /** - * Update attachments UI - */ - private _updateAttachmentsUI(): void { - this._view?.webview.postMessage({ - type: 'updateAttachments', - attachments: this._attachments - } as ToWebviewMessage); - } - - /** - * Get file icon based on extension - */ - private _getFileIcon(filename: string): string { - const ext = filename.split('.').pop()?.toLowerCase() || ''; - const iconMap: Record = { - 'ts': 'file-code', 'tsx': 'file-code', 'js': 'file-code', 'jsx': 'file-code', - 'py': 'file-code', 'java': 'file-code', 'c': 'file-code', 'cpp': 'file-code', - 'html': 'file-code', 'css': 'file-code', 'scss': 'file-code', - 'json': 'json', 'yaml': 'file-code', 'yml': 'file-code', - 'md': 'markdown', 'txt': 'file-text', - 'png': 'file-media', 'jpg': 'file-media', 'jpeg': 'file-media', 'gif': 'file-media', 'svg': 'file-media', - 'sh': 'terminal', 'bash': 'terminal', 'ps1': 'terminal', - 'zip': 'file-zip', 'tar': 'file-zip', 'gz': 'file-zip' - }; - return iconMap[ext] || 'file'; - } - - /** - * Handle adding a prompt to queue - */ - private _handleAddQueuePrompt(prompt: string, id: string, attachments: AttachmentInfo[]): void { - const trimmed = prompt.trim(); - if (!trimmed || trimmed.length > this._MAX_QUEUE_PROMPT_LENGTH) return; - - const queuedPrompt: QueuedPrompt = { - id: id || `q_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - prompt: trimmed, - attachments: attachments.length > 0 ? [...attachments] : undefined // Store attachments if any - }; - - // Check if we should auto-respond BEFORE adding to queue (race condition fix) - // This prevents the window between push and findIndex where queue could be modified - const shouldAutoRespond = this._queueEnabled && - this._currentToolCallId && - this._pendingRequests.has(this._currentToolCallId); - - if (shouldAutoRespond) { - // Don't add to queue - consume directly for the pending request - const resolve = this._pendingRequests.get(this._currentToolCallId!); - if (!resolve) return; - - // Update the pending entry to completed - const pendingEntry = this._currentSessionCallsMap.get(this._currentToolCallId!); - - let completedEntry: ToolCallEntry; - if (pendingEntry && pendingEntry.status === 'pending') { - pendingEntry.response = queuedPrompt.prompt; - pendingEntry.attachments = queuedPrompt.attachments; - pendingEntry.status = 'completed'; - pendingEntry.isFromQueue = true; - pendingEntry.timestamp = Date.now(); - completedEntry = pendingEntry; - } else { - completedEntry = { - id: this._currentToolCallId!, - prompt: 'Tool call', - response: queuedPrompt.prompt, - attachments: queuedPrompt.attachments, - timestamp: Date.now(), - isFromQueue: true, - status: 'completed' - }; - this._currentSessionCalls.unshift(completedEntry); - this._currentSessionCallsMap.set(completedEntry.id, completedEntry); - } - - // Send toolCallCompleted to webview - this._view?.webview.postMessage({ - type: 'toolCallCompleted', - entry: completedEntry - } as ToWebviewMessage); - - this._updateCurrentSessionUI(); - this._saveQueueToDisk(); - this._updateQueueUI(); - - resolve({ value: queuedPrompt.prompt, queue: this._queueEnabled && this._promptQueue.length > 0, attachments: queuedPrompt.attachments || [] }); - this._pendingRequests.delete(this._currentToolCallId!); - this._currentToolCallId = null; - } else { - // No pending request - add to queue normally - this._promptQueue.push(queuedPrompt); - this._saveQueueToDisk(); - this._updateQueueUI(); - } - - // Clear attachments after adding to queue (they're now stored with the queue item) - // This prevents old images from reappearing when pasting new images - this._attachments = []; - this._updateAttachmentsUI(); - } - - /** - * Validate queue prompt ID format (defense in depth) - */ - private _isValidQueueId(id: unknown): id is string { - return typeof id === 'string' && /^q_\d+_[a-z0-9]+$/.test(id); - } - - /** - * Handle removing a prompt from queue - */ - private _handleRemoveQueuePrompt(promptId: string): void { - if (!this._isValidQueueId(promptId)) return; - this._promptQueue = this._promptQueue.filter(p => p.id !== promptId); - this._saveQueueToDisk(); - this._updateQueueUI(); - } - - /** - * Handle editing a prompt in queue - */ - private _handleEditQueuePrompt(promptId: string, newPrompt: string): void { - if (!this._isValidQueueId(promptId)) return; - const trimmed = newPrompt.trim(); - if (!trimmed || trimmed.length > this._MAX_QUEUE_PROMPT_LENGTH) return; - - const prompt = this._promptQueue.find(p => p.id === promptId); - if (prompt) { - prompt.prompt = trimmed; - this._saveQueueToDisk(); - this._updateQueueUI(); - } - } - - /** - * Handle reordering queue - */ - private _handleReorderQueue(fromIndex: number, toIndex: number): void { - if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) return; - if (fromIndex < 0 || toIndex < 0) return; - if (fromIndex >= this._promptQueue.length || toIndex >= this._promptQueue.length) return; - - const [removed] = this._promptQueue.splice(fromIndex, 1); - this._promptQueue.splice(toIndex, 0, removed); - this._saveQueueToDisk(); - this._updateQueueUI(); - } - - /** - * Handle toggling queue enabled state - */ - private _handleToggleQueue(enabled: boolean): void { - this._queueEnabled = enabled; - this._saveQueueToDisk(); - this._updateQueueUI(); - } - - /** - * Handle clearing the queue - */ - private _handleClearQueue(): void { - this._promptQueue = []; - this._saveQueueToDisk(); - this._updateQueueUI(); - } - - /** - * Handle removing a history item from persisted history (modal only) - */ - private _handleRemoveHistoryItem(callId: string): void { - this._persistedHistory = this._persistedHistory.filter(tc => tc.id !== callId); - this._updatePersistedHistoryUI(); - this._savePersistedHistoryToDisk(); - } - - /** - * Handle clearing all persisted history - */ - private _handleClearPersistedHistory(): void { - this._persistedHistory = []; - this._updatePersistedHistoryUI(); - this._savePersistedHistoryToDisk(); - } - - /** - * Handle opening history modal - send persisted history to webview - */ - private _handleOpenHistoryModal(): void { - this._updatePersistedHistoryUI(); - } - - /** - * Handle opening settings modal - send settings to webview - */ - private _handleOpenSettingsModal(): void { - this._updateSettingsUI(); - } - - /** - * Handle updating sound setting - */ - private async _handleUpdateSoundSetting(enabled: boolean): Promise { - this._soundEnabled = enabled; - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('notificationSound', enabled, vscode.ConfigurationTarget.Global); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating interactive approval setting - */ - private async _handleUpdateInteractiveApprovalSetting(enabled: boolean): Promise { - this._interactiveApprovalEnabled = enabled; - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('interactiveApproval', enabled, vscode.ConfigurationTarget.Global); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating autopilot setting - */ - private async _handleUpdateAutopilotSetting(enabled: boolean): Promise { - this._autopilotEnabled = enabled; - // Reset consecutive auto-responses when autopilot is toggled - this._consecutiveAutoResponses = 0; - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('autopilot', enabled, vscode.ConfigurationTarget.Workspace); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating autopilot text - */ - private async _handleUpdateAutopilotText(text: string): Promise { - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - const normalizedText = this._normalizeAutopilotText(text, config); - this._autopilotText = normalizedText; - await config.update('autopilotText', normalizedText, vscode.ConfigurationTarget.Workspace); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle adding a new autopilot prompt to the cycling array - */ - private async _handleAddAutopilotPrompt(prompt: string): Promise { - const trimmedPrompt = prompt.trim(); - if (!trimmedPrompt) return; - - this._autopilotPrompts.push(trimmedPrompt); - await this._saveAutopilotPrompts(); - this._updateSettingsUI(); - } - - /** - * Handle editing an autopilot prompt at a specific index - */ - private async _handleEditAutopilotPrompt(index: number, prompt: string): Promise { - const trimmedPrompt = prompt.trim(); - if (!trimmedPrompt || index < 0 || index >= this._autopilotPrompts.length) return; - - this._autopilotPrompts[index] = trimmedPrompt; - await this._saveAutopilotPrompts(); - this._updateSettingsUI(); - } - - /** - * Handle removing an autopilot prompt at a specific index - */ - private async _handleRemoveAutopilotPrompt(index: number): Promise { - if (index < 0 || index >= this._autopilotPrompts.length) return; - - this._autopilotPrompts.splice(index, 1); - - // Adjust index to track the same prompt after deletion - if (this._autopilotIndex > index) { - this._autopilotIndex--; - } else if (this._autopilotIndex >= this._autopilotPrompts.length) { - this._autopilotIndex = 0; - } - - await this._saveAutopilotPrompts(); - this._updateSettingsUI(); - } - - /** - * Handle reordering autopilot prompts (drag and drop) - */ - private async _handleReorderAutopilotPrompts(fromIndex: number, toIndex: number): Promise { - if (fromIndex < 0 || fromIndex >= this._autopilotPrompts.length || - toIndex < 0 || toIndex >= this._autopilotPrompts.length || - fromIndex === toIndex) { - return; - } - - // Adjust autopilot index to track the same prompt after reorder - if (this._autopilotIndex === fromIndex) { - // The prompt we're currently on moved - this._autopilotIndex = toIndex; - } else if (fromIndex < this._autopilotIndex && toIndex >= this._autopilotIndex) { - // A prompt before our position moved after it - this._autopilotIndex--; - } else if (fromIndex > this._autopilotIndex && toIndex <= this._autopilotIndex) { - // A prompt after our position moved before it - this._autopilotIndex++; - } - - // Remove from old position and insert at new position - const [removed] = this._autopilotPrompts.splice(fromIndex, 1); - this._autopilotPrompts.splice(toIndex, 0, removed); - - await this._saveAutopilotPrompts(); - this._updateSettingsUI(); - } - - /** - * Save autopilot prompts to VS Code configuration - */ - private async _saveAutopilotPrompts(): Promise { - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('autopilotPrompts', this._autopilotPrompts, vscode.ConfigurationTarget.Global); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating response timeout setting - */ - private async _handleUpdateResponseTimeout(value: number): Promise { - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - const normalizedValue = this._normalizeResponseTimeout(value); - await config.update('responseTimeout', String(normalizedValue), vscode.ConfigurationTarget.Workspace); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating session warning threshold in hours. - */ - private async _handleUpdateSessionWarningHours(value: number): Promise { - if (!Number.isFinite(value)) { - return; - } - - const normalizedValue = Math.min(8, Math.max(0, Math.floor(value))); - this._sessionWarningHours = normalizedValue; - - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('sessionWarningHours', normalizedValue, vscode.ConfigurationTarget.Workspace); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating max consecutive auto-responses setting - */ - private async _handleUpdateMaxConsecutiveAutoResponses(value: number): Promise { - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('maxConsecutiveAutoResponses', value, vscode.ConfigurationTarget.Workspace); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle toggling human-like delay setting - */ - private async _handleUpdateHumanDelaySetting(enabled: boolean): Promise { - this._humanLikeDelayEnabled = enabled; - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('humanLikeDelay', enabled, vscode.ConfigurationTarget.Workspace); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle updating human-like delay minimum setting - */ - private async _handleUpdateHumanDelayMin(value: number): Promise { - if (value >= 1 && value <= 30) { - this._humanLikeDelayMin = value; - // Ensure min <= max; persist both if adjusted - let adjustedMax = false; - if (this._humanLikeDelayMin > this._humanLikeDelayMax) { - this._humanLikeDelayMax = this._humanLikeDelayMin; - adjustedMax = true; - } - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('humanLikeDelayMin', value, vscode.ConfigurationTarget.Workspace); - if (adjustedMax) { - await config.update('humanLikeDelayMax', this._humanLikeDelayMax, vscode.ConfigurationTarget.Workspace); - } - } finally { - this._isUpdatingConfig = false; - } - } - } - - /** - * Handle updating human-like delay maximum setting - */ - private async _handleUpdateHumanDelayMax(value: number): Promise { - if (value >= 2 && value <= 60) { - this._humanLikeDelayMax = value; - // Ensure max >= min; persist both if adjusted - let adjustedMin = false; - if (this._humanLikeDelayMax < this._humanLikeDelayMin) { - this._humanLikeDelayMin = this._humanLikeDelayMax; - adjustedMin = true; - } - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('humanLikeDelayMax', value, vscode.ConfigurationTarget.Workspace); - if (adjustedMin) { - await config.update('humanLikeDelayMin', this._humanLikeDelayMin, vscode.ConfigurationTarget.Workspace); - } - } finally { - this._isUpdatingConfig = false; - } - } - } - - /** - * Handle updating send behavior setting. - */ - private async _handleUpdateSendWithCtrlEnterSetting(enabled: boolean): Promise { - this._sendWithCtrlEnter = enabled; - this._isUpdatingConfig = true; - try { - const config = vscode.workspace.getConfiguration('tasksync'); - await config.update('sendWithCtrlEnter', enabled, vscode.ConfigurationTarget.Global); - } finally { - this._isUpdatingConfig = false; - } - } - - /** - * Handle adding a reusable prompt - */ - private async _handleAddReusablePrompt(name: string, prompt: string): Promise { - const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-'); - const trimmedPrompt = prompt.trim(); - - if (!trimmedName || !trimmedPrompt) return; - - // Check for duplicate names - if (this._reusablePrompts.some(p => p.name.toLowerCase() === trimmedName)) { - vscode.window.showWarningMessage(`A prompt with name "/${trimmedName}" already exists.`); - return; - } - - const newPrompt: ReusablePrompt = { - id: `rp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - name: trimmedName, - prompt: trimmedPrompt - }; - - this._reusablePrompts.push(newPrompt); - await this._saveReusablePrompts(); - this._updateSettingsUI(); - } - - /** - * Handle editing a reusable prompt - */ - private async _handleEditReusablePrompt(id: string, name: string, prompt: string): Promise { - const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-'); - const trimmedPrompt = prompt.trim(); - - if (!trimmedName || !trimmedPrompt) return; - - const existingPrompt = this._reusablePrompts.find(p => p.id === id); - if (!existingPrompt) return; - - // Check for duplicate names (excluding current prompt) - if (this._reusablePrompts.some(p => p.id !== id && p.name.toLowerCase() === trimmedName)) { - vscode.window.showWarningMessage(`A prompt with name "/${trimmedName}" already exists.`); - return; - } - - existingPrompt.name = trimmedName; - existingPrompt.prompt = trimmedPrompt; - - await this._saveReusablePrompts(); - this._updateSettingsUI(); - } - - /** - * Handle removing a reusable prompt - */ - private async _handleRemoveReusablePrompt(id: string): Promise { - this._reusablePrompts = this._reusablePrompts.filter(p => p.id !== id); - await this._saveReusablePrompts(); - this._updateSettingsUI(); - } - - /** - * Handle searching slash commands for autocomplete - */ - private _handleSearchSlashCommands(query: string): void { - const queryLower = query.toLowerCase(); - const matchingPrompts = this._reusablePrompts.filter(p => - p.name.toLowerCase().includes(queryLower) || - p.prompt.toLowerCase().includes(queryLower) - ); - - this._view?.webview.postMessage({ - type: 'slashCommandResults', - prompts: matchingPrompts - } as ToWebviewMessage); - } - - /** - * Open an external URL from webview using a strict protocol allowlist. - */ - private _handleOpenExternalLink(url: string): void { - if (!url) { - return; - } - - try { - const parsed = vscode.Uri.parse(url); - const allowedSchemes = ['http', 'https', 'mailto']; - if (!allowedSchemes.includes(parsed.scheme)) { - vscode.window.showWarningMessage(`Unsupported link protocol: ${parsed.scheme}`); - return; - } - - void vscode.env.openExternal(parsed); - } catch (error) { - console.error('[TaskSync] Failed to open external link:', error); - vscode.window.showWarningMessage('Unable to open external link'); - } - } - - /** - * Copy plain text to the system clipboard via extension host API. - */ - private async _handleCopyToClipboard(text: string): Promise { - if (typeof text !== 'string' || text.length === 0) { - return; - } - - try { - await vscode.env.clipboard.writeText(text); - } catch (error) { - console.error('[TaskSync] Failed to copy text to clipboard:', error); - vscode.window.showWarningMessage('Unable to copy content to clipboard'); - } - } - - /** - * Open a file link from webview and reveal requested line or line range when provided. - */ - private async _handleOpenFileLink(target: string): Promise { - if (!target) { - return; - } - - const parsedTarget = this._parseFileLinkTarget(target); - if (!parsedTarget.filePath) { - vscode.window.showWarningMessage('File link does not contain a valid path'); - return; - } - - const fileUri = this._resolveFileLinkUri(parsedTarget.filePath); - if (!fileUri) { - vscode.window.showWarningMessage(`File not found: ${parsedTarget.filePath}`); - return; - } - - try { - const document = await vscode.workspace.openTextDocument(fileUri); - const editor = await vscode.window.showTextDocument(document, { preview: false }); - - if (parsedTarget.startLine !== null) { - const maxLine = Math.max(document.lineCount - 1, 0); - const startLine = Math.min(Math.max(parsedTarget.startLine - 1, 0), maxLine); - const requestedEnd = parsedTarget.endLine ?? parsedTarget.startLine; - const endLine = Math.min(Math.max(requestedEnd - 1, startLine), maxLine); - const endCharacter = document.lineAt(endLine).range.end.character; - const range = new vscode.Range(startLine, 0, endLine, endCharacter); - - editor.selection = new vscode.Selection(startLine, 0, endLine, endCharacter); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - } - } catch (error) { - console.error('[TaskSync] Failed to open file link:', error); - vscode.window.showWarningMessage(`Unable to open file: ${parsedTarget.filePath}`); - } - } - - /** - * Parse file link target in format "path#Lx" or "path#Lx-Ly". - */ - private _parseFileLinkTarget(target: string): { filePath: string; startLine: number | null; endLine: number | null } { - const trimmedTarget = target.trim(); - const match = /^(.*?)(?:#L(\d+)(?:-L(\d+))?)?$/.exec(trimmedTarget); - const parseLine = (value: string | undefined): number | null => { - if (!value) { - return null; - } - - const parsedValue = Number.parseInt(value, 10); - return Number.isFinite(parsedValue) ? parsedValue : null; - }; - - const filePath = (match?.[1] ?? trimmedTarget).trim(); - const startLine = parseLine(match?.[2]); - const endLine = parseLine(match?.[3]); - - return { - filePath, - startLine, - endLine - }; - } - - /** - * Resolve a file link path to an existing file URI. - */ - private _resolveFileLinkUri(rawPath: string): vscode.Uri | null { - const normalizedPath = rawPath.trim().replace(/^\.\//, '').trim(); - if (!normalizedPath) { - return null; - } - - try { - const parsedUri = vscode.Uri.parse(normalizedPath); - if (parsedUri.scheme === 'file' && fs.existsSync(parsedUri.fsPath) && fs.statSync(parsedUri.fsPath).isFile()) { - return parsedUri; - } - } catch (error) { - // Treat as path when parsing as URI fails. - } - - if (path.isAbsolute(normalizedPath)) { - if (fs.existsSync(normalizedPath) && fs.statSync(normalizedPath).isFile()) { - return vscode.Uri.file(path.resolve(normalizedPath)); - } - return null; - } - - const workspaceFolders = vscode.workspace.workspaceFolders || []; - if (workspaceFolders.length === 0) { - return null; - } - - for (const folder of workspaceFolders) { - const candidatePath = path.resolve(folder.uri.fsPath, normalizedPath); - if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) { - return vscode.Uri.file(candidatePath); - } - } - - return null; - } - - /** - * Handle searching context references (#terminal, #problems) - deprecated, now handled via file search - */ - private async _handleSearchContext(query: string): Promise { - try { - const suggestions = await this._contextManager.getContextSuggestions(query); - this._view?.webview.postMessage({ - type: 'contextSearchResults', - suggestions: suggestions.map(s => ({ - type: s.type, - label: s.label, - description: s.description, - detail: s.detail - })) - } as ToWebviewMessage); - } catch (error) { - console.error('[TaskSync] Error searching context:', error); - this._view?.webview.postMessage({ - type: 'contextSearchResults', - suggestions: [] - } as ToWebviewMessage); - } - } - - /** - * Handle selecting a context reference to add as attachment - */ - private async _handleSelectContextReference(contextType: string, options?: Record): Promise { - try { - const reference = await this._contextManager.getContextContent( - contextType as ContextReferenceType, - options - ); - - if (reference) { - // Add context reference as a special attachment - const contextAttachment: AttachmentInfo = { - id: reference.id, - name: reference.label, - uri: `context://${reference.type}/${reference.id}`, - isTextReference: true - }; - this._attachments.push(contextAttachment); - this._updateAttachmentsUI(); - - // Also send the reference content so it can be displayed - this._view?.webview.postMessage({ - type: 'contextReferenceAdded', - reference: { - id: reference.id, - type: reference.type, - label: reference.label, - content: reference.content - } - } as ToWebviewMessage); - } else { - // Still add a placeholder attachment showing it was selected but empty - const emptyId = `ctx_empty_${Date.now()}`; - const friendlyType = contextType.replace(':', ' '); - const contextAttachment: AttachmentInfo = { - id: emptyId, - name: `#${friendlyType} (no content)`, - uri: `context://${contextType}/${emptyId}`, - isTextReference: true - }; - this._attachments.push(contextAttachment); - this._updateAttachmentsUI(); - - // Show info message - vscode.window.showInformationMessage(`No ${contextType} content available yet`); - } - } catch (error) { - console.error('[TaskSync] Error selecting context reference:', error); - vscode.window.showErrorMessage(`Failed to get ${contextType} content`); - } - } - - /** - * Resolve context content from a context URI - * URI format: context://type/id - */ - public async resolveContextContent(uri: string): Promise { - try { - const parsed = vscode.Uri.parse(uri); - if (parsed.scheme !== 'context') return undefined; - - const type = parsed.authority as ContextReferenceType; - // id is likely in path, e.g. /id - const id = parsed.path.startsWith('/') ? parsed.path.substring(1) : parsed.path; - - const contextRef = await this._contextManager.getContextContent(type); - return contextRef?.content; - - } catch (error) { - console.error('[TaskSync] Error resolving context content:', error); - return undefined; - } - } - - /** - * Update queue UI in webview - */ - private _updateQueueUI(): void { - this._view?.webview.postMessage({ - type: 'updateQueue', - queue: this._promptQueue, - enabled: this._queueEnabled - } as ToWebviewMessage); - } - - /** - * Update current session UI in webview (cards in chat) - */ - private _updateCurrentSessionUI(): void { - this._view?.webview.postMessage({ - type: 'updateCurrentSession', - history: this._currentSessionCalls - } as ToWebviewMessage); - } - - /** - * Update persisted history UI in webview (for modal) - * Includes completed calls from the current session so they're visible - * without waiting for session end / extension deactivation. - */ - private _updatePersistedHistoryUI(): void { - const currentCompleted = this._currentSessionCalls.filter(tc => tc.status === 'completed'); - const combined = [...currentCompleted, ...this._persistedHistory].slice(0, this._MAX_HISTORY_ENTRIES); - this._view?.webview.postMessage({ - type: 'updatePersistedHistory', - history: combined - } as ToWebviewMessage); - } - - /** - * Get workspace-aware storage URI. - * Uses storageUri (workspace-specific) if available, otherwise falls back to globalStorageUri. - * This ensures data is isolated per-workspace when a workspace is open. - */ - private _getStorageUri(): vscode.Uri { - return this._context.storageUri || this._context.globalStorageUri; - } - - /** - * Load queue from disk - */ - private async _loadQueueFromDiskAsync(): Promise { - try { - const storagePath = this._getStorageUri().fsPath; - const queuePath = path.join(storagePath, 'queue.json'); - - // Check if file exists using async - try { - await fs.promises.access(queuePath, fs.constants.F_OK); - } catch { - // File doesn't exist, use defaults - this._promptQueue = []; - this._queueEnabled = true; - return; - } - - const data = await fs.promises.readFile(queuePath, 'utf8'); - const parsed = JSON.parse(data); - this._promptQueue = Array.isArray(parsed.queue) ? parsed.queue : []; - this._queueEnabled = parsed.enabled === true; - } catch (error) { - console.error('Failed to load queue:', error); - this._promptQueue = []; - this._queueEnabled = true; // Default to queue mode - } - } - - /** - * Save queue to disk (debounced) - */ - private _saveQueueToDisk(): void { - if (this._queueSaveTimer) { - clearTimeout(this._queueSaveTimer); - } - this._queueSaveTimer = setTimeout(() => { - this._saveQueueToDiskAsync(); - }, this._QUEUE_SAVE_DEBOUNCE_MS); - } - - /** - * Actually persist queue to disk - */ - private async _saveQueueToDiskAsync(): Promise { - try { - const storagePath = this._getStorageUri().fsPath; - const queuePath = path.join(storagePath, 'queue.json'); - - if (!fs.existsSync(storagePath)) { - await fs.promises.mkdir(storagePath, { recursive: true }); - } - - const data = JSON.stringify({ - queue: this._promptQueue, - enabled: this._queueEnabled - }, null, 2); - - await fs.promises.writeFile(queuePath, data, 'utf8'); - } catch (error) { - console.error('Failed to save queue:', error); - } - } - - /** - * Load persisted history from disk (past sessions only) - ASYNC to not block activation - */ - private async _loadPersistedHistoryFromDiskAsync(): Promise { - try { - const storagePath = this._getStorageUri().fsPath; - const historyPath = path.join(storagePath, 'tool-history.json'); - - // Check if file exists using async stat - try { - await fs.promises.access(historyPath, fs.constants.F_OK); - } catch { - // File doesn't exist, use empty history - this._persistedHistory = []; - return; - } - - const data = await fs.promises.readFile(historyPath, 'utf8'); - const parsed = JSON.parse(data); - // Only load completed entries from past sessions, enforce max limit - this._persistedHistory = Array.isArray(parsed.history) - ? parsed.history - .filter((entry: ToolCallEntry) => entry.status === 'completed') - .slice(0, this._MAX_HISTORY_ENTRIES) - : []; - } catch (error) { - console.error('[TaskSync] Failed to load persisted history:', error); - this._persistedHistory = []; - } - } - - /** - * Save persisted history to disk with debounced async write - * Uses background async saves to avoid blocking the main thread - */ - private _savePersistedHistoryToDisk(): void { - this._historyDirty = true; - - // Cancel any pending save - if (this._historySaveTimer) { - clearTimeout(this._historySaveTimer); - } - - // Schedule debounced async save - this._historySaveTimer = setTimeout(() => { - this._savePersistedHistoryToDiskAsync(); - }, this._HISTORY_SAVE_DEBOUNCE_MS); - } - - /** - * Async save persisted history (non-blocking background save) - */ - private async _savePersistedHistoryToDiskAsync(): Promise { - try { - const storagePath = this._getStorageUri().fsPath; - const historyPath = path.join(storagePath, 'tool-history.json'); - - // Use async fs operations from fs/promises - const fsPromises = await import('fs/promises'); - - try { - await fsPromises.access(storagePath); - } catch { - await fsPromises.mkdir(storagePath, { recursive: true }); - } - - // Only save completed entries from our in-memory history - const completedHistory = this._persistedHistory.filter(entry => entry.status === 'completed'); - - // Merge with existing on-disk history to preserve entries from other windows - let merged = completedHistory; - try { - const existing = await fsPromises.readFile(historyPath, 'utf8'); - const parsed = JSON.parse(existing); - if (Array.isArray(parsed.history)) { - const knownIds = new Set(completedHistory.map(e => e.id)); - const diskOnly = parsed.history.filter((e: ToolCallEntry) => !knownIds.has(e.id)); - merged = [...completedHistory, ...diskOnly] - .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) - .slice(0, this._MAX_HISTORY_ENTRIES); - } - } catch { - // File doesn't exist or is invalid — just use our entries - } - - // Update in-memory history with the merged result - this._persistedHistory = merged; - - const data = JSON.stringify({ - history: merged - }, null, 2); - - await fsPromises.writeFile(historyPath, data, 'utf8'); - this._historyDirty = false; - } catch (error) { - console.error('[TaskSync] Failed to save persisted history (async):', error); - } - } - - /** - * Actually persist history to disk (synchronous - only for deactivate) - * Called during extension deactivation when async operations cannot complete - */ - private _savePersistedHistoryToDiskSync(): void { - // Only save if there are pending changes - if (!this._historyDirty) return; - - try { - const storagePath = this._getStorageUri().fsPath; - const historyPath = path.join(storagePath, 'tool-history.json'); - - if (!fs.existsSync(storagePath)) { - fs.mkdirSync(storagePath, { recursive: true }); - } - - // Only save completed entries from our in-memory history - const completedHistory = this._persistedHistory.filter(entry => entry.status === 'completed'); - - // Merge with existing on-disk history to preserve entries from other windows - let merged = completedHistory; - try { - const existing = fs.readFileSync(historyPath, 'utf8'); - const parsed = JSON.parse(existing); - if (Array.isArray(parsed.history)) { - const knownIds = new Set(completedHistory.map(e => e.id)); - const diskOnly = parsed.history.filter((e: ToolCallEntry) => !knownIds.has(e.id)); - merged = [...completedHistory, ...diskOnly] - .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) - .slice(0, this._MAX_HISTORY_ENTRIES); - } - } catch { - // File doesn't exist or is invalid — just use our entries - } - - // Update in-memory history with the merged result - this._persistedHistory = merged; - - const data = JSON.stringify({ - history: merged - }, null, 2); - - fs.writeFileSync(historyPath, data, 'utf8'); - this._historyDirty = false; - } catch (error) { - console.error('[TaskSync] Failed to save persisted history:', error); - } - } - - /** - * Generate HTML content for webview - */ - private _getHtmlContent(webview: vscode.Webview): string { - const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'main.css')); - const markdownLinksScriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'markdownLinks.js')); - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'webview.js')); - const codiconsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.css')); - const logoUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'TS-logo.svg')); - const notificationSoundUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'notification.wav')); - const nonce = this._getNonce(); - - return ` - - - - - - - - TaskSync Chat - - - -
    - -
    - -
    -
    - -
    -

    Let's build

    -

    Sync your tasks, automate your workflow

    - -
    -
    -
    - - Normal -
    -

    Respond to each AI request directly. Full control over every interaction.

    -
    -
    -
    - - Queue -
    -

    Batch your responses. AI consumes from queue automatically, one by one.

    -
    -
    - -

    Tip: Enable Autopilot to automatically respond to ask_user prompts without waiting for your input, using a customizable prompt you can configure in Settings. Queued prompts always take priority over Autopilot responses. Configure the session timeout in settings to avoid keeping copilot session alive when you're away.

    -

    The session timer tracks how long you've been using one premium request. It is advisable to start a new session and use another premium request prompt after 2-4 hours or 50 tool calls.

    -
    - - -
    - - - -
    - - -
    - - - - -
    - -
    -
    - - Prompt Queue - 0 -
    -
    -
    No prompts in queue
    -
    -
    - - -
    - - -
    -
    - - -
    -
    -
    -
    - -
    - -
    -
    -
    - Autopilot -
    - -
    -
    -
    - - -
    -
    -
    - - - -`; - } - - /** - * Generate a nonce for CSP - */ - private _getNonce(): string { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; - } - - /** - * Parse choices from a question text. - * Detects numbered lists (1. 2. 3.), lettered options (A. B. C.), and Option X: patterns. - * Only detects choices near the LAST question mark "?" to avoid false positives from - * earlier numbered/lettered content in the text. - * - * @param text - The question text to parse - * @returns Array of parsed choices, empty if no choices detected - */ - private _parseChoices(text: string): ParsedChoice[] { - const choices: ParsedChoice[] = []; - let match; - - // Search the ENTIRE text for numbered/lettered lists, not just after the last "?" - // The previous approach failed when examples within the text contained "?" characters - // (e.g., "Example: What's your favorite language?") - - // Strategy: Find the FIRST major numbered/lettered list that starts early in the text - // These are the actual choices, not examples or descriptions within the text - - // Split entire text into lines for multi-line patterns - const lines = text.split('\n'); - - // Pattern 1: Numbered options - lines starting with "1." or "1)" through 9 - // Also match bold numbered options like "**1. Option**" - const numberedLinePattern = /^\s*\*{0,2}(\d+)[.)]\s*\*{0,2}\s*(.+)$/; - const numberedLines: { index: number; num: string; numValue: number; text: string }[] = []; - for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(numberedLinePattern); - if (m && m[2].trim().length >= 3) { - // Clean up markdown bold markers from text - const cleanText = m[2].replace(/\*\*/g, '').trim(); - numberedLines.push({ - index: i, - num: m[1], - numValue: parseInt(m[1], 10), - text: cleanText - }); - } - } - - // Find the FIRST contiguous list (which contains the main choices) - // Previously used LAST list which missed choices when examples appeared later in text - if (numberedLines.length >= 2) { - // Find all list boundaries by detecting number restarts - const listBoundaries: number[] = [0]; // First list starts at index 0 - - for (let i = 1; i < numberedLines.length; i++) { - const prevNum = numberedLines[i - 1].numValue; - const currNum = numberedLines[i].numValue; - const lineGap = numberedLines[i].index - numberedLines[i - 1].index; - - // Detect a new list if: - // 1. Number resets (e.g., 2 -> 1, or any case where current < previous) - // 2. Large gap between lines (> 5 lines typically means different section) - if (currNum <= prevNum || lineGap > 5) { - listBoundaries.push(i); - } - } - - // Get the FIRST list (the main choices list) - // The first numbered list is typically the actual choices - // Later lists are often examples or descriptions within each choice - const firstListEnd = listBoundaries.length > 1 ? listBoundaries[1] : numberedLines.length; - const firstGroup = numberedLines.slice(0, firstListEnd); - - if (firstGroup.length >= 2) { - for (const m of firstGroup) { - let cleanText = m.text.replace(/[?!]+$/, '').trim(); - const displayText = cleanText.length > 40 ? cleanText.substring(0, 37) + '...' : cleanText; - choices.push({ - label: displayText, - value: m.num, - shortLabel: m.num - }); - } - return choices; - } - } - - // Pattern 1b: Inline numbered lists "1. option 2. option 3. option" or "1 - option 2 - option" - const inlineNumberedPattern = /(\d+)(?:[.):]|\s+-)\s+([^0-9]+?)(?=\s+\d+(?:[.):]|\s+-)|$)/g; - const inlineNumberedMatches: { num: string; text: string }[] = []; - - // Only try inline if no multi-line matches found - // Use full text converted to single line - const singleLine = text.replace(/\n/g, ' '); - while ((match = inlineNumberedPattern.exec(singleLine)) !== null) { - const optionText = match[2].trim(); - if (optionText.length >= 3) { - inlineNumberedMatches.push({ num: match[1], text: optionText }); - } - } - - if (inlineNumberedMatches.length >= 2) { - for (const m of inlineNumberedMatches) { - let cleanText = m.text.replace(/[?!]+$/, '').trim(); - const displayText = cleanText.length > 40 ? cleanText.substring(0, 37) + '...' : cleanText; - choices.push({ - label: displayText, - value: m.num, - shortLabel: m.num - }); - } - return choices; - } - - // Pattern 2: Lettered options - lines starting with "A." or "A)" or "**A)" through Z - // Also match bold lettered options like "**A) Option**" - // FIX: Search entire text, not just after question mark - const letteredLinePattern = /^\s*\*{0,2}([A-Za-z])[.)]\s*\*{0,2}\s*(.+)$/; - const letteredLines: { index: number; letter: string; text: string }[] = []; - - for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(letteredLinePattern); - if (m && m[2].trim().length >= 3) { - // Clean up markdown bold markers from text - const cleanText = m[2].replace(/\*\*/g, '').trim(); - letteredLines.push({ index: i, letter: m[1].toUpperCase(), text: cleanText }); - } - } - - if (letteredLines.length >= 2) { - // Find all list boundaries by detecting letter restarts or gaps - const listBoundaries: number[] = [0]; - - for (let i = 1; i < letteredLines.length; i++) { - const gap = letteredLines[i].index - letteredLines[i - 1].index; - // Detect new list if gap > 3 lines - if (gap > 3) { - listBoundaries.push(i); - } - } - - // Get the FIRST list (the main choices list) - const firstListEnd = listBoundaries.length > 1 ? listBoundaries[1] : letteredLines.length; - const firstGroup = letteredLines.slice(0, firstListEnd); - - if (firstGroup.length >= 2) { - for (const m of firstGroup) { - let cleanText = m.text.replace(/[?!]+$/, '').trim(); - const displayText = cleanText.length > 40 ? cleanText.substring(0, 37) + '...' : cleanText; - choices.push({ - label: displayText, - value: m.letter, - shortLabel: m.letter - }); - } - return choices; - } - } - - // Pattern 2b: Inline lettered "A. option B. option C. option" - // Only match single uppercase letters to avoid false positives - const inlineLetteredPattern = /\b([A-Z])[.)]\s+([^A-Z]+?)(?=\s+[A-Z][.)]|$)/g; - const inlineLetteredMatches: { letter: string; text: string }[] = []; - - while ((match = inlineLetteredPattern.exec(singleLine)) !== null) { - const optionText = match[2].trim(); - if (optionText.length >= 3) { - inlineLetteredMatches.push({ letter: match[1], text: optionText }); - } - } - - if (inlineLetteredMatches.length >= 2) { - for (const m of inlineLetteredMatches) { - let cleanText = m.text.replace(/[?!]+$/, '').trim(); - const displayText = cleanText.length > 40 ? cleanText.substring(0, 37) + '...' : cleanText; - choices.push({ - label: displayText, - value: m.letter, - shortLabel: m.letter - }); - } - return choices; - } - - // Pattern 3: "Option A:" or "Option 1:" style - // Search entire text for this pattern - const optionPattern = /option\s+([A-Za-z1-9])\s*:\s*([^O\n]+?)(?=\s*Option\s+[A-Za-z1-9]|\s*$|\n)/gi; - const optionMatches: { id: string; text: string }[] = []; - - while ((match = optionPattern.exec(text)) !== null) { - const optionText = match[2].trim(); - if (optionText.length >= 3) { - optionMatches.push({ id: match[1].toUpperCase(), text: optionText }); - } - } - - if (optionMatches.length >= 2) { - for (const m of optionMatches) { - let cleanText = m.text.replace(/[?!]+$/, '').trim(); - const displayText = cleanText.length > 40 ? cleanText.substring(0, 37) + '...' : cleanText; - choices.push({ - label: displayText, - value: `Option ${m.id}`, - shortLabel: m.id - }); - } - return choices; - } - - return choices; - } - - /** - * Detect if a question is an approval/confirmation type that warrants quick action buttons. - * Uses NLP patterns to identify yes/no questions, permission requests, and confirmations. - * - * @param text - The question text to analyze - * @returns true if the question is an approval-type question - */ - private _isApprovalQuestion(text: string): boolean { - const lowerText = text.toLowerCase(); - - // NEGATIVE patterns - questions that require specific input (NOT approval questions) - const requiresSpecificInput = [ - // Generic "select/choose an option" prompts - these need specific choice, not yes/no - /please (?:select|choose|pick) (?:an? )?option/i, - /select (?:an? )?option/i, - // Open-ended requests for feedback/information - /let me know/i, - /tell me (?:what|how|when|if|about)/i, - /waiting (?:for|on) (?:your|the)/i, - /ready to (?:hear|see|get|receive)/i, - // Questions asking for specific information - /what (?:is|are|should|would)/i, - /which (?:one|file|option|method|approach)/i, - /where (?:should|would|is|are)/i, - /how (?:should|would|do|can)/i, - /when (?:should|would)/i, - /who (?:should|would)/i, - // Questions asking for names, values, content - /(?:enter|provide|specify|give|type|input|write)\s+(?:a|the|your)/i, - /what.*(?:name|value|path|url|content|text|message)/i, - /please (?:enter|provide|specify|give|type)/i, - // Open-ended questions - /describe|explain|elaborate|clarify/i, - /tell me (?:about|more|how)/i, - /what do you (?:think|want|need|prefer)/i, - /any (?:suggestions|recommendations|preferences|thoughts)/i, - // Questions with multiple choice indicators (not binary) - /choose (?:from|between|one of)/i, - /select (?:from|one of|which)/i, - /pick (?:one|from|between)/i, - // Numbered options (1. 2. 3. or 1) 2) 3)) - /\n\s*[1-9][.)]\s+\S/i, - // Lettered options (A. B. C. or a) b) c) or Option A/B/C) - /\n\s*[a-d][.)]\s+\S/i, - /option\s+[a-d]\s*:/i, - // "Would you like me to:" followed by list - /would you like (?:me to|to):\s*\n/i, - // ASCII art boxes/mockups (common patterns) - /[┌├└│┐┤┘─╔╠╚║╗╣╝═]/, - /\[.+\]\s+\[.+\]/i, // Multiple bracketed options like [Approve] [Reject] - // "Something else?" at the end of a list typically means multi-choice - /\d+[.)]\s+something else\??/i - ]; - - // Check if question requires specific input - if so, NOT an approval question - for (const pattern of requiresSpecificInput) { - if (pattern.test(lowerText)) { - return false; - } - } - - // Also check for numbered lists anywhere in text (strong indicator of multi-choice) - const numberedListCount = (text.match(/\n\s*\d+[.)]\s+/g) || []).length; - if (numberedListCount >= 2) { - return false; // Multiple numbered items = multi-choice question - } - - // POSITIVE patterns - approval/confirmation questions - const approvalPatterns = [ - // Direct yes/no question patterns - /^(?:shall|should|can|could|may|would|will|do|does|did|is|are|was|were|have|has|had)\s+(?:i|we|you|it|this|that)\b/i, - // Permission/confirmation phrases - /(?:proceed|continue|go ahead|start|begin|execute|run|apply|commit|save|delete|remove|create|add|update|modify|change|overwrite|replace)/i, - /(?:ok|okay|alright|ready|confirm|approve|accept|allow|enable|disable|skip|ignore|dismiss|close|cancel|abort|stop|exit|quit)/i, - // Question endings that suggest yes/no - /\?$/, - /(?:right|correct|yes|no)\s*\?$/i, - /(?:is that|does that|would that|should that)\s+(?:ok|okay|work|help|be\s+(?:ok|fine|good|acceptable))/i, - // Explicit approval requests - /(?:do you want|would you like|shall i|should i|can i|may i|could i)/i, - /(?:want me to|like me to|need me to)/i, - /(?:approve|confirm|authorize|permit|allow)\s+(?:this|the|these)/i, - // Binary choice indicators - /(?:yes or no|y\/n|yes\/no|\[y\/n\]|\(y\/n\))/i, - // Action confirmation patterns - /(?:are you sure|do you confirm|please confirm|confirm that)/i, - /(?:this will|this would|this is going to)/i - ]; - - // Check if any approval pattern matches - for (const pattern of approvalPatterns) { - if (pattern.test(lowerText)) { - return true; - } - } - - // Additional heuristic: short questions ending with ? are likely yes/no - if (lowerText.length < this._SHORT_QUESTION_THRESHOLD && lowerText.trim().endsWith('?')) { - // But exclude questions with interrogative words that typically need specific answers - const interrogatives = /^(?:what|which|where|when|why|how|who|whom|whose)\b/i; - if (!interrogatives.test(lowerText.trim())) { - return true; - } - } - - return false; - } -} +import * as vscode from "vscode"; +import { + CONFIG_SECTION, + DEFAULT_HUMAN_LIKE_DELAY_MAX, + DEFAULT_HUMAN_LIKE_DELAY_MIN, + DEFAULT_SESSION_WARNING_HOURS, +} from "../constants/remoteConstants"; +import { ContextManager, ContextReferenceType } from "../context"; +import type { RemoteServer } from "../server/remoteServer"; +import * as fileH from "./fileHandlers"; +import * as lifecycle from "./lifecycleHandlers"; +import * as router from "./messageRouter"; +import * as persist from "./persistence"; +import * as remote from "./remoteApiHandlers"; +import * as session from "./sessionManager"; +import * as settingsH from "./settingsHandlers"; +import * as toolCall from "./toolCallHandler"; +import { + type AttachmentInfo, + type FileSearchResult, + type FromWebviewMessage, + type QueuedPrompt, + type ReusablePrompt, + type ToolCallEntry, + type ToWebviewMessage, + type UserResponseResult, + VIEW_TYPE, +} from "./webviewTypes"; +import { debugLog, mergeAndDedup } from "./webviewUtils"; + +// Re-export types for external consumers +export type { + AttachmentInfo, + FileSearchResult, + ParsedChoice, + QueuedPrompt, + ReusablePrompt, + ToolCallEntry, + UserResponseResult, +} from "./webviewTypes"; + +export class TaskSyncWebviewProvider + implements vscode.WebviewViewProvider, vscode.Disposable +{ + public static readonly viewType = VIEW_TYPE; + + // All underscore-prefixed members are "internal" by convention but public + // for handler module access. See webviewTypes.ts P type. + _view?: vscode.WebviewView; + _pendingRequests: Map void> = + new Map(); + + // Prompt queue state + _promptQueue: QueuedPrompt[] = []; + _queueVersion: number = 0; // Monotonic counter for remote sync + _queueEnabled: boolean = true; // Default to queue mode + + // Attachments state + _attachments: AttachmentInfo[] = []; + + // Current session tool calls (memory only - not persisted during session) + _currentSessionCalls: ToolCallEntry[] = []; + + // Persisted history from past sessions (loaded from disk) + _persistedHistory: ToolCallEntry[] = []; + _currentToolCallId: string | null = null; + + // Tracks whether the AI is actively working (between user response and next askUser call) + _aiTurnActive: boolean = false; + + // Last known chat model name (fetched from VS Code LM API) + _lastKnownModel: string = ""; + + // Webview ready state - prevents race condition on first message + _webviewReady: boolean = false; + _pendingToolCallMessage: { + id: string; + prompt: string; + summary?: string; + } | null = null; + + // Debounce timer for queue persistence + _queueSaveTimer: ReturnType | null = null; + + readonly _QUEUE_SAVE_DEBOUNCE_MS = 300; + + // Debounce timer for history persistence (async background saves) + _historySaveTimer: ReturnType | null = null; + readonly _HISTORY_SAVE_DEBOUNCE_MS = 2000; // 2 seconds debounce + _historyDirty: boolean = false; // Track if history needs saving + + // Performance limits (SSOT: MAX_QUEUE_PROMPT_LENGTH, MAX_QUEUE_SIZE, MAX_RESPONSE_LENGTH imported from remoteConstants.ts) + readonly _MAX_HISTORY_ENTRIES = 100; + readonly _MAX_FILE_SEARCH_RESULTS = 500; + readonly _MAX_FOLDER_SEARCH_RESULTS = 1000; + readonly _VIEW_OPEN_TIMEOUT_MS = 5000; + readonly _VIEW_OPEN_POLL_INTERVAL_MS = 100; + + // File search cache with TTL + _fileSearchCache: Map< + string, + { results: FileSearchResult[]; timestamp: number } + > = new Map(); + readonly _FILE_CACHE_TTL_MS = 5000; + + // Map for O(1) lookup of tool calls by ID (synced with _currentSessionCalls array) + _currentSessionCallsMap: Map = new Map(); + + // Reusable prompts (loaded from VS Code settings) + _reusablePrompts: ReusablePrompt[] = []; + + // Notification sound enabled (loaded from VS Code settings) + _soundEnabled: boolean = true; + + // Interactive approval buttons enabled (loaded from VS Code settings) + _interactiveApprovalEnabled: boolean = true; + + readonly _AUTOPILOT_DEFAULT_TEXT = + "You are temporarily in autonomous mode and must now make your own decision. If another question arises, be sure to ask it, as autonomous mode is temporary."; + readonly _SESSION_TERMINATION_TEXT = + "Session terminated. Do not use askUser tool again."; + + // Autopilot enabled (loaded from VS Code settings) + _autopilotEnabled: boolean = false; + + // Autopilot text (legacy, kept for backward compatibility) + _autopilotText: string = ""; + + // Autopilot prompts array (cycles through in order) + _autopilotPrompts: string[] = []; + + // Current index in autopilot prompts cycle (resets on new session) + _autopilotIndex: number = 0; + + // Human-like delay settings: adds random jitter before auto-responses. + // Simulates natural human reading/typing time for a more realistic workflow. + _humanLikeDelayEnabled: boolean = true; + _humanLikeDelayMin: number = DEFAULT_HUMAN_LIKE_DELAY_MIN; + _humanLikeDelayMax: number = DEFAULT_HUMAN_LIKE_DELAY_MAX; + + // Session warning threshold (hours). 0 disables the warning. + _sessionWarningHours: number = DEFAULT_SESSION_WARNING_HOURS; + + // Allowed timeout values now imported from remoteConstants.ts (SSOT) + // RESPONSE_TIMEOUT_ALLOWED_VALUES, RESPONSE_TIMEOUT_DEFAULT_MINUTES + + // Send behavior: false => Enter, true => Ctrl/Cmd+Enter + _sendWithCtrlEnter: boolean = false; + + // Flag to prevent config reload during our own updates (avoids race condition) + _isUpdatingConfig: boolean = false; + + // Disposables to clean up + _disposables: vscode.Disposable[] = []; + + // Context manager for #terminal, #problems references + readonly _contextManager: ContextManager; + + // Response timeout tracking + _responseTimeoutTimer: ReturnType | null = null; + _consecutiveAutoResponses: number = 0; + + // Session timer (resets on new session) + _sessionStartTime: number | null = null; // timestamp when first tool call occurred + _sessionFrozenElapsed: number | null = null; // frozen elapsed ms when session terminated + _sessionTimerInterval: ReturnType | null = null; + // Flag indicating the session was terminated (next tool call auto-starts new session) + _sessionTerminated: boolean = false; + // Flag to ensure the 2-hour session warning is only shown once per session + _sessionWarningShown: boolean = false; + + // Remote server reference for broadcasting state changes + _remoteServer: RemoteServer | null = null; + + constructor( + public readonly _extensionUri: vscode.Uri, + public readonly _context: vscode.ExtensionContext, + contextManager: ContextManager, + ) { + this._contextManager = contextManager; + // Load both queue and history async to not block activation + this._loadQueueFromDiskAsync().catch((err) => { + console.error("[TaskSync] Failed to load queue:", err); + }); + this._loadPersistedHistoryFromDiskAsync().catch((err) => { + console.error("[TaskSync] Failed to load history:", err); + }); + // Load settings (sync - fast operation) + this._loadSettings(); + + // Fetch the current chat model name asynchronously + this.refreshChatModel(); + + // Re-fetch models when the available model list changes + this._disposables.push( + vscode.lm.onDidChangeChatModels(() => { + debugLog("[TaskSync] onDidChangeChatModels — refreshing model info"); + this.refreshChatModel(); + }), + ); + + // Listen for settings changes + this._disposables.push( + vscode.workspace.onDidChangeConfiguration((e) => { + // Skip reload if we're the ones updating config (prevents race condition) + if (this._isUpdatingConfig) { + return; + } + if ( + e.affectsConfiguration(`${CONFIG_SECTION}.notificationSound`) || + e.affectsConfiguration(`${CONFIG_SECTION}.interactiveApproval`) || + e.affectsConfiguration(`${CONFIG_SECTION}.autopilot`) || + e.affectsConfiguration(`${CONFIG_SECTION}.autopilotText`) || + e.affectsConfiguration(`${CONFIG_SECTION}.autopilotPrompts`) || + e.affectsConfiguration(`${CONFIG_SECTION}.autoAnswer`) || + e.affectsConfiguration(`${CONFIG_SECTION}.autoAnswerText`) || + e.affectsConfiguration(`${CONFIG_SECTION}.reusablePrompts`) || + e.affectsConfiguration(`${CONFIG_SECTION}.responseTimeout`) || + e.affectsConfiguration(`${CONFIG_SECTION}.sessionWarningHours`) || + e.affectsConfiguration( + `${CONFIG_SECTION}.maxConsecutiveAutoResponses`, + ) || + e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelay`) || + e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelayMin`) || + e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelayMax`) || + e.affectsConfiguration(`${CONFIG_SECTION}.sendWithCtrlEnter`) + ) { + this._loadSettings(); + this._updateSettingsUI(); + // Broadcast all settings to remote clients + settingsH.broadcastAllSettingsToRemote(this); + } + }), + ); + } + + /** + * Save current tool call history to persisted history (called on deactivate) + * Uses synchronous save because deactivate cannot await async operations + */ + public saveCurrentSessionToHistory(): void { + // Cancel any pending debounced saves + if (this._historySaveTimer) { + clearTimeout(this._historySaveTimer); + this._historySaveTimer = null; + } + + // Only save completed calls from current session + const completedCalls = this._currentSessionCalls.filter( + (tc) => tc.status === "completed", + ); + debugLog( + `[TaskSync] saveCurrentSessionToHistory — completedCalls: ${completedCalls.length}, persistedHistory: ${this._persistedHistory.length}`, + ); + if (completedCalls.length > 0) { + this._persistedHistory = mergeAndDedup( + completedCalls, + this._persistedHistory, + this._MAX_HISTORY_ENTRIES, + ); + this._historyDirty = true; + } + + // Force sync save on deactivation (async operations can't complete in deactivate) + this._savePersistedHistoryToDiskSync(); + } + + public openHistoryModal(): void { + this._view?.webview.postMessage({ + type: "openHistoryModal", + } satisfies ToWebviewMessage); + this._updatePersistedHistoryUI(); + } + + public openSettingsModal(): void { + this._view?.webview.postMessage({ + type: "openSettingsModal", + } satisfies ToWebviewMessage); + this._updateSettingsUI(); + } + + public triggerSendFromShortcut(): void { + this._view?.webview.postMessage({ + type: "triggerSendFromShortcut", + } satisfies ToWebviewMessage); + } + + public startNewSession(): void { + lifecycle.startNewSession(this); + } + + public playNotificationSound(): void { + if (this._soundEnabled) { + session.playSystemSound(); + this._view?.webview.postMessage({ + type: "playNotificationSound", + } satisfies ToWebviewMessage); + } + } + + async _applyHumanLikeDelay(label?: string): Promise { + return session.applyHumanLikeDelay(this, label); + } + + _updateViewTitle(): void { + session.updateViewTitle(this); + } + _startSessionTimerInterval(): void { + session.startSessionTimerInterval(this); + } + _stopSessionTimerInterval(): void { + session.stopSessionTimerInterval(this); + } + + _loadSettings(): void { + settingsH.loadSettings(this); + } + + /** + * Update settings UI in webview + */ + _updateSettingsUI(): void { + settingsH.updateSettingsUI(this); + } + + // ==================== Remote Server Integration ==================== + + /** + * Fetch the current chat model name from VS Code LM API (used by remote API). + */ + async refreshChatModel(): Promise { + try { + const models = await vscode.lm.selectChatModels({ vendor: "copilot" }); + if (models.length > 0) { + this._lastKnownModel = + models[0].name || models[0].family || models[0].id; + debugLog( + `[TaskSync] refreshChatModel — found ${models.length} models, first: ${this._lastKnownModel}`, + ); + } + } catch { + // LM API not available — leave blank + } + } + + /** + * Set the remote server reference for broadcasting state changes + */ + public setRemoteServer(server: RemoteServer): void { + debugLog("[TaskSync] setRemoteServer — remote server attached"); + this._remoteServer = server; + } + + /** + * Get current state for remote clients + */ + public getRemoteState(): ReturnType { + return remote.getRemoteState(this); + } + + public resolveRemoteResponse( + toolCallId: string, + value: string, + attachments: AttachmentInfo[], + ): boolean { + return remote.resolveRemoteResponse(this, toolCallId, value, attachments); + } + + public addToQueueFromRemote( + prompt: string, + attachments: AttachmentInfo[], + ): { error?: string; code?: string } { + return remote.addToQueueFromRemote(this, prompt, attachments); + } + + public removeFromQueueById(id: string): void { + remote.removeFromQueueById(this, id); + } + + public editQueuePromptFromRemote( + promptId: string, + newPrompt: string, + ): { error?: string; code?: string } { + return remote.editQueuePromptFromRemote(this, promptId, newPrompt); + } + + public reorderQueueFromRemote(fromIndex: number, toIndex: number): void { + remote.reorderQueueFromRemote(this, fromIndex, toIndex); + } + + public clearQueueFromRemote(): void { + remote.clearQueueFromRemote(this); + } + + public async setAutopilotEnabled(enabled: boolean): Promise { + return remote.setAutopilotEnabled(this, enabled); + } + + public async searchFilesForRemote( + query: string, + ): Promise { + return remote.searchFilesForRemote(this, query); + } + + public setQueueEnabled(enabled: boolean): void { + remote.setQueueEnabled(this, enabled); + } + + public async setResponseTimeoutFromRemote(timeout: number): Promise { + return remote.setResponseTimeoutFromRemote(this, timeout); + } + + public cancelPendingToolCall(reason?: string): boolean { + return remote.cancelPendingToolCall(this, reason); + } + + // ==================== End Remote Server Integration ==================== + + public dispose(): void { + lifecycle.disposeProvider(this); + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + debugLog("[TaskSync] resolveWebviewView — setting up webview"); + lifecycle.setupWebviewView(this, webviewView); + } + + public async waitForUserResponse( + question: string, + summary?: string, + ): Promise { + return toolCall.waitForUserResponse(this, question, summary); + } + + /** + * Check if queue is enabled + */ + public isQueueEnabled(): boolean { + return this._queueEnabled; + } + + _handleWebviewMessage(message: FromWebviewMessage): void { + router.handleWebviewMessage(this, message); + } + + _updateAttachmentsUI(): void { + fileH.updateAttachmentsUI(this); + } + + /** + * Resolve context content from a context URI + * URI format: context://type/id + */ + public async resolveContextContent(uri: string): Promise { + try { + const parsed = vscode.Uri.parse(uri); + if (parsed.scheme !== "context") return undefined; + + const type = parsed.authority as ContextReferenceType; + const contextRef = await this._contextManager.getContextContent(type); + return contextRef?.content; + } catch (error) { + console.error("[TaskSync] Error resolving context content:", error); + return undefined; + } + } + + /** + * Update queue UI in webview + */ + _updateQueueUI(): void { + this._view?.webview.postMessage({ + type: "updateQueue", + queue: this._promptQueue, + enabled: this._queueEnabled, + } satisfies ToWebviewMessage); + } + + /** + * Update current session UI in webview (cards in chat) + */ + _updateCurrentSessionUI(): void { + this._view?.webview.postMessage({ + type: "updateCurrentSession", + history: this._currentSessionCalls, + } satisfies ToWebviewMessage); + } + + /** + * Update persisted history UI in webview (for modal) + * Includes completed calls from the current session so they're visible + * without waiting for session end / extension deactivation. + */ + _updatePersistedHistoryUI(): void { + const currentCompleted = this._currentSessionCalls.filter( + (tc) => tc.status === "completed", + ); + const seen = new Set(); + const combined = [...currentCompleted, ...this._persistedHistory] + .filter((entry) => { + if (seen.has(entry.id)) return false; + seen.add(entry.id); + return true; + }) + .slice(0, this._MAX_HISTORY_ENTRIES); + this._view?.webview.postMessage({ + type: "updatePersistedHistory", + history: combined, + } satisfies ToWebviewMessage); + } + + private async _loadQueueFromDiskAsync(): Promise { + return persist.loadQueueFromDiskAsync(this); + } + + _saveQueueToDisk(): void { + persist.saveQueueToDisk(this); + } + + private async _saveQueueToDiskAsync(): Promise { + return persist.saveQueueToDiskAsync(this); + } + + private async _loadPersistedHistoryFromDiskAsync(): Promise { + return persist.loadPersistedHistoryFromDiskAsync(this); + } + + _savePersistedHistoryToDisk(): void { + persist.savePersistedHistoryToDisk(this); + } + + private async _savePersistedHistoryToDiskAsync(): Promise { + return persist.savePersistedHistoryToDiskAsync(this); + } + + private _savePersistedHistoryToDiskSync(): void { + persist.savePersistedHistoryToDiskSync(this); + } +} + +/** Internal type alias for handler modules — provides compile-time safety via P. */ +export type ProviderInternal = TaskSyncWebviewProvider; diff --git a/tasksync-chat/src/webview/webviewTypes.ts b/tasksync-chat/src/webview/webviewTypes.ts new file mode 100644 index 0000000..d5a151d --- /dev/null +++ b/tasksync-chat/src/webview/webviewTypes.ts @@ -0,0 +1,190 @@ +import type { ProviderInternal } from "./webviewProvider"; + +/** + * WebviewProvider instance type — used by extracted handler modules. + * Re-exported from webviewProvider.ts to avoid circular value imports. + * This gives full compile-time type checking for all 67 accessed members. + */ +export type P = ProviderInternal; + +/** View type identifier — the SSOT for "taskSyncView" used across the extension */ +export const VIEW_TYPE = "taskSyncView"; + +// Queued prompt interface +export interface QueuedPrompt { + id: string; + prompt: string; + attachments?: AttachmentInfo[]; // Optional attachments (images, files) included with the prompt +} + +// Attachment info +export interface AttachmentInfo { + id: string; + name: string; + uri: string; + isTemporary?: boolean; + isFolder?: boolean; + isTextReference?: boolean; +} + +// File search result (also used for context items like #terminal, #problems and tools) +export interface FileSearchResult { + name: string; + path: string; + uri: string; + icon: string; + isFolder?: boolean; + isContext?: boolean; // true for #terminal, #problems context items + isTool?: boolean; // true for LM tool references +} + +// User response result +export interface UserResponseResult { + value: string; + queue: boolean; + attachments: AttachmentInfo[]; + cancelled?: boolean; // Indicates if the request was superseded by a new one +} + +// Tool call history entry +export interface ToolCallEntry { + id: string; + prompt: string; + summary?: string; + response: string; + timestamp: number; + isFromQueue: boolean; + status: "pending" | "completed" | "cancelled"; + attachments?: AttachmentInfo[]; +} + +// Parsed choice from question +export interface ParsedChoice { + label: string; // Display text (e.g., "1" or "Test functionality") + value: string; // Response value to send (e.g., "1" or full text) + shortLabel?: string; // Short version for button (e.g., "1" for numbered) +} + +// Reusable prompt interface +export interface ReusablePrompt { + id: string; + name: string; // Short name for /slash command (e.g., "fix", "test", "refactor") + prompt: string; // Full prompt text +} + +// Message types sent from extension to webview +export type ToWebviewMessage = + | { type: "updateQueue"; queue: QueuedPrompt[]; enabled: boolean } + | { + type: "toolCallPending"; + id: string; + prompt: string; + summary?: string; + isApproval: boolean; + choices?: ParsedChoice[]; + } + | { + type: "toolCallCompleted"; + entry: ToolCallEntry; + sessionTerminated?: boolean; + } + | { type: "updateCurrentSession"; history: ToolCallEntry[] } + | { type: "updatePersistedHistory"; history: ToolCallEntry[] } + | { type: "fileSearchResults"; files: FileSearchResult[] } + | { type: "updateAttachments"; attachments: AttachmentInfo[] } + | { type: "imageSaved"; attachment: AttachmentInfo } + | { type: "openSettingsModal" } + | { + type: "updateSettings"; + soundEnabled: boolean; + interactiveApprovalEnabled: boolean; + autopilotEnabled: boolean; + autopilotText: string; + autopilotPrompts: string[]; + reusablePrompts: ReusablePrompt[]; + responseTimeout: number; + sessionWarningHours: number; + maxConsecutiveAutoResponses: number; + humanLikeDelayEnabled: boolean; + humanLikeDelayMin: number; + humanLikeDelayMax: number; + sendWithCtrlEnter: boolean; + queueEnabled: boolean; + } + | { type: "slashCommandResults"; prompts: ReusablePrompt[] } + | { type: "playNotificationSound" } + | { + type: "contextSearchResults"; + suggestions: Array<{ + type: string; + label: string; + description: string; + detail: string; + }>; + } + | { + type: "contextReferenceAdded"; + reference: { id: string; type: string; label: string; content: string }; + } + | { type: "clear" } + | { + type: "updateSessionTimer"; + startTime: number | null; + frozenElapsed: number | null; + } + | { type: "triggerSendFromShortcut" } + | { type: "openHistoryModal" }; + +// Message types sent from webview to extension +export type FromWebviewMessage = + | { type: "submit"; value: string; attachments: AttachmentInfo[] } + | { + type: "addQueuePrompt"; + prompt: string; + id: string; + attachments?: AttachmentInfo[]; + } + | { type: "removeQueuePrompt"; promptId: string } + | { type: "editQueuePrompt"; promptId: string; newPrompt: string } + | { type: "reorderQueue"; fromIndex: number; toIndex: number } + | { type: "toggleQueue"; enabled: boolean } + | { type: "clearQueue" } + | { type: "addAttachment" } + | { type: "removeAttachment"; attachmentId: string } + | { type: "removeHistoryItem"; callId: string } + | { type: "clearPersistedHistory" } + | { type: "openHistoryModal" } + | { type: "newSession" } + | { type: "searchFiles"; query: string } + | { type: "saveImage"; data: string; mimeType: string } + | { type: "addFileReference"; file: FileSearchResult } + | { type: "webviewReady" } + | { type: "openSettingsModal" } + | { type: "updateSoundSetting"; enabled: boolean } + | { type: "updateInteractiveApprovalSetting"; enabled: boolean } + | { type: "updateAutopilotSetting"; enabled: boolean } + | { type: "updateAutopilotText"; text: string } + | { type: "addAutopilotPrompt"; prompt: string } + | { type: "editAutopilotPrompt"; index: number; prompt: string } + | { type: "removeAutopilotPrompt"; index: number } + | { type: "reorderAutopilotPrompts"; fromIndex: number; toIndex: number } + | { type: "addReusablePrompt"; name: string; prompt: string } + | { type: "editReusablePrompt"; id: string; name: string; prompt: string } + | { type: "removeReusablePrompt"; id: string } + | { type: "searchSlashCommands"; query: string } + | { type: "openExternal"; url: string } + | { type: "openFileLink"; target: string } + | { type: "updateResponseTimeout"; value: number } + | { type: "updateSessionWarningHours"; value: number } + | { type: "updateMaxConsecutiveAutoResponses"; value: number } + | { type: "updateHumanDelaySetting"; enabled: boolean } + | { type: "updateHumanDelayMin"; value: number } + | { type: "updateHumanDelayMax"; value: number } + | { type: "updateSendWithCtrlEnterSetting"; enabled: boolean } + | { type: "searchContext"; query: string } + | { + type: "selectContextReference"; + contextType: string; + options?: Record; + } + | { type: "copyToClipboard"; text: string }; diff --git a/tasksync-chat/src/webview/webviewUtils.test.ts b/tasksync-chat/src/webview/webviewUtils.test.ts new file mode 100644 index 0000000..2f5ed16 --- /dev/null +++ b/tasksync-chat/src/webview/webviewUtils.test.ts @@ -0,0 +1,478 @@ +import * as fs from "fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { + broadcastToolCallCompleted, + formatElapsed, + getFileIcon, + getHumanLikeDelayMs, + getNonce, + hasQueuedItems, + markSessionTerminated, + notifyQueueChanged, + parseFileLinkTarget, + resolveFileLinkUri, +} from "../webview/webviewUtils"; + +vi.mock("fs", () => ({ + existsSync: vi.fn(() => false), + statSync: vi.fn(() => ({ isFile: () => true })), +})); + +// ─── formatElapsed ─────────────────────────────────────────── + +describe("formatElapsed", () => { + it("formats seconds only", () => { + expect(formatElapsed(0)).toBe("0s"); + expect(formatElapsed(1000)).toBe("1s"); + expect(formatElapsed(59000)).toBe("59s"); + }); + + it("formats minutes and seconds", () => { + expect(formatElapsed(60000)).toBe("1m 0s"); + expect(formatElapsed(90000)).toBe("1m 30s"); + expect(formatElapsed(3599000)).toBe("59m 59s"); + }); + + it("formats hours, minutes, and seconds", () => { + expect(formatElapsed(3600000)).toBe("1h 0m 0s"); + expect(formatElapsed(3661000)).toBe("1h 1m 1s"); + expect(formatElapsed(7200000)).toBe("2h 0m 0s"); + }); + + it("floors sub-second values", () => { + expect(formatElapsed(500)).toBe("0s"); + expect(formatElapsed(1999)).toBe("1s"); + }); +}); + +// ─── getHumanLikeDelayMs ───────────────────────────────────── + +describe("getHumanLikeDelayMs", () => { + it("returns 0 when disabled", () => { + expect(getHumanLikeDelayMs(false, 1, 5)).toBe(0); + }); + + it("returns a value within range when enabled", () => { + for (let i = 0; i < 20; i++) { + const result = getHumanLikeDelayMs(true, 1, 3); + expect(result).toBeGreaterThanOrEqual(1000); + expect(result).toBeLessThanOrEqual(3000); + } + }); + + it("works with equal min and max", () => { + const result = getHumanLikeDelayMs(true, 2, 2); + expect(result).toBe(2000); + }); +}); + +// ─── getFileIcon ───────────────────────────────────────────── + +describe("getFileIcon", () => { + it("maps code file extensions", () => { + expect(getFileIcon("app.ts")).toBe("file-code"); + expect(getFileIcon("index.tsx")).toBe("file-code"); + expect(getFileIcon("main.js")).toBe("file-code"); + expect(getFileIcon("component.jsx")).toBe("file-code"); + expect(getFileIcon("script.py")).toBe("file-code"); + expect(getFileIcon("App.java")).toBe("file-code"); + expect(getFileIcon("page.html")).toBe("file-code"); + expect(getFileIcon("style.css")).toBe("file-code"); + }); + + it("maps data file extensions", () => { + expect(getFileIcon("config.json")).toBe("json"); + expect(getFileIcon("README.md")).toBe("markdown"); + expect(getFileIcon("notes.txt")).toBe("file-text"); + }); + + it("maps media file extensions", () => { + expect(getFileIcon("photo.png")).toBe("file-media"); + expect(getFileIcon("image.jpg")).toBe("file-media"); + expect(getFileIcon("icon.svg")).toBe("file-media"); + }); + + it("maps terminal file extensions", () => { + expect(getFileIcon("setup.sh")).toBe("terminal"); + expect(getFileIcon("run.bash")).toBe("terminal"); + }); + + it("maps archive file extensions", () => { + expect(getFileIcon("bundle.zip")).toBe("file-zip"); + expect(getFileIcon("archive.tar")).toBe("file-zip"); + expect(getFileIcon("compressed.gz")).toBe("file-zip"); + }); + + it("returns 'file' for unknown extensions", () => { + expect(getFileIcon("data.xyz")).toBe("file"); + expect(getFileIcon("noext")).toBe("file"); + }); +}); + +// ─── parseFileLinkTarget ───────────────────────────────────── + +describe("parseFileLinkTarget", () => { + it("parses path without line numbers", () => { + const result = parseFileLinkTarget("src/app.ts"); + expect(result).toEqual({ + filePath: "src/app.ts", + startLine: null, + endLine: null, + }); + }); + + it("parses path with single line number", () => { + const result = parseFileLinkTarget("src/app.ts#L42"); + expect(result).toEqual({ + filePath: "src/app.ts", + startLine: 42, + endLine: null, + }); + }); + + it("parses path with line range", () => { + const result = parseFileLinkTarget("src/app.ts#L10-L20"); + expect(result).toEqual({ + filePath: "src/app.ts", + startLine: 10, + endLine: 20, + }); + }); + + it("trims whitespace from path", () => { + const result = parseFileLinkTarget(" src/app.ts "); + expect(result.filePath).toBe("src/app.ts"); + }); + + it("handles empty string", () => { + const result = parseFileLinkTarget(""); + expect(result.filePath).toBe(""); + expect(result.startLine).toBeNull(); + }); + + it("handles path with no match gracefully", () => { + const result = parseFileLinkTarget("some/path"); + expect(result.filePath).toBe("some/path"); + expect(result.startLine).toBeNull(); + expect(result.endLine).toBeNull(); + }); +}); + +// ─── hasQueuedItems ────────────────────────────────────────── + +describe("hasQueuedItems", () => { + it("returns true when queue is enabled and has items", () => { + const p = { + _queueEnabled: true, + _promptQueue: [{ id: "1", prompt: "test" }], + } as any; + expect(hasQueuedItems(p)).toBe(true); + }); + + it("returns false when queue is disabled", () => { + const p = { + _queueEnabled: false, + _promptQueue: [{ id: "1", prompt: "test" }], + } as any; + expect(hasQueuedItems(p)).toBe(false); + }); + + it("returns false when queue is empty", () => { + const p = { _queueEnabled: true, _promptQueue: [] } as any; + expect(hasQueuedItems(p)).toBe(false); + }); + + it("returns false when both disabled and empty", () => { + const p = { _queueEnabled: false, _promptQueue: [] } as any; + expect(hasQueuedItems(p)).toBe(false); + }); +}); + +// ─── getNonce ──────────────────────────────────────────────── + +describe("getNonce", () => { + it("returns a 32-char hex string", () => { + const nonce = getNonce(); + expect(nonce).toMatch(/^[0-9a-f]{32}$/); + }); + + it("returns unique values on each call", () => { + const a = getNonce(); + const b = getNonce(); + expect(a).not.toBe(b); + }); +}); + +// ─── notifyQueueChanged ───────────────────────────────────── + +describe("notifyQueueChanged", () => { + it("increments version, saves, updates UI, and broadcasts", () => { + const broadcast = vi.fn(); + const p = { + _queueVersion: 5, + _promptQueue: [ + { + id: "q1", + prompt: "test prompt", + attachments: [{ id: "a1", name: "f.txt", uri: "file:///f.txt" }], + }, + ], + _saveQueueToDisk: vi.fn(), + _updateQueueUI: vi.fn(), + _remoteServer: { broadcast }, + } as any; + + notifyQueueChanged(p); + + expect(p._queueVersion).toBe(6); + expect(p._saveQueueToDisk).toHaveBeenCalled(); + expect(p._updateQueueUI).toHaveBeenCalled(); + expect(broadcast).toHaveBeenCalledWith("queueChanged", { + queue: [ + { + id: "q1", + prompt: "test prompt", + attachments: [{ id: "a1", name: "f.txt", uri: "file:///f.txt" }], + }, + ], + queueVersion: 6, + }); + }); + + it("handles missing remoteServer gracefully", () => { + const p = { + _queueVersion: 0, + _promptQueue: [], + _saveQueueToDisk: vi.fn(), + _updateQueueUI: vi.fn(), + _remoteServer: null, + } as any; + + expect(() => notifyQueueChanged(p)).not.toThrow(); + expect(p._queueVersion).toBe(1); + }); + + it("adds empty attachments array for items without attachments", () => { + const broadcast = vi.fn(); + const p = { + _queueVersion: 0, + _promptQueue: [{ id: "q1", prompt: "test" }], + _saveQueueToDisk: vi.fn(), + _updateQueueUI: vi.fn(), + _remoteServer: { broadcast }, + } as any; + + notifyQueueChanged(p); + + const broadcastedQueue = broadcast.mock.calls[0][1].queue; + expect(broadcastedQueue[0].attachments).toEqual([]); + }); +}); + +// ─── broadcastToolCallCompleted ────────────────────────────── + +describe("broadcastToolCallCompleted", () => { + it("broadcasts tool call entry to remote clients", () => { + const broadcast = vi.fn(); + const p = { _remoteServer: { broadcast } } as any; + const entry = { + id: "tc1", + prompt: "Do something", + response: "Done", + timestamp: 1234567890, + status: "completed" as const, + attachments: [], + isFromQueue: false, + }; + + broadcastToolCallCompleted(p, entry); + + expect(broadcast).toHaveBeenCalledWith("toolCallCompleted", { + id: "tc1", + entry: { + id: "tc1", + prompt: "Do something", + response: "Done", + timestamp: 1234567890, + status: "completed", + attachments: [], + isFromQueue: false, + }, + sessionTerminated: false, + }); + }); + + it("passes sessionTerminated flag when provided", () => { + const broadcast = vi.fn(); + const p = { _remoteServer: { broadcast } } as any; + const entry = { + id: "tc2", + prompt: "p", + response: "r", + timestamp: 0, + status: "completed" as const, + attachments: [], + isFromQueue: true, + }; + + broadcastToolCallCompleted(p, entry, true); + + expect(broadcast.mock.calls[0][1].sessionTerminated).toBe(true); + }); + + it("handles missing remoteServer gracefully", () => { + const p = { _remoteServer: null } as any; + const entry = { + id: "tc3", + prompt: "p", + response: "r", + timestamp: 0, + status: "pending" as const, + attachments: [], + isFromQueue: false, + }; + + expect(() => broadcastToolCallCompleted(p, entry)).not.toThrow(); + }); +}); + +// ─── markSessionTerminated ─────────────────────────────────── + +describe("markSessionTerminated", () => { + it("sets terminated flag and freezes elapsed time", () => { + const now = Date.now(); + const p = { + _sessionTerminated: false, + _sessionStartTime: now - 5000, + _sessionFrozenElapsed: 0, + _stopSessionTimerInterval: vi.fn(), + _updateViewTitle: vi.fn(), + } as any; + + markSessionTerminated(p); + + expect(p._sessionTerminated).toBe(true); + expect(p._sessionFrozenElapsed).toBeGreaterThanOrEqual(4900); + expect(p._sessionFrozenElapsed).toBeLessThanOrEqual(6000); + expect(p._stopSessionTimerInterval).toHaveBeenCalled(); + expect(p._updateViewTitle).toHaveBeenCalled(); + }); + + it("handles null sessionStartTime (no active session)", () => { + const p = { + _sessionTerminated: false, + _sessionStartTime: null, + _sessionFrozenElapsed: 0, + _stopSessionTimerInterval: vi.fn(), + _updateViewTitle: vi.fn(), + } as any; + + markSessionTerminated(p); + + expect(p._sessionTerminated).toBe(true); + expect(p._sessionFrozenElapsed).toBe(0); + expect(p._stopSessionTimerInterval).not.toHaveBeenCalled(); + expect(p._updateViewTitle).not.toHaveBeenCalled(); + }); +}); + +// ─── resolveFileLinkUri ────────────────────────────────────── + +describe("resolveFileLinkUri", () => { + afterEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + it("returns null for empty string", () => { + expect(resolveFileLinkUri("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(resolveFileLinkUri(" ")).toBeNull(); + }); + + it("returns null when file does not exist (absolute path)", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + expect(resolveFileLinkUri("/nonexistent/file.ts")).toBeNull(); + }); + + it("returns Uri for existing absolute path", () => { + // Make Uri.parse throw so it falls through to isAbsolute check + const origParse = vscode.Uri.parse; + (vscode.Uri as any).parse = () => { + throw new Error("not a URI"); + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); + const result = resolveFileLinkUri("/existing/file.ts"); + expect(result).not.toBeNull(); + (vscode.Uri as any).parse = origParse; + }); + + it("returns null for absolute path that is a directory", () => { + const origParse = vscode.Uri.parse; + (vscode.Uri as any).parse = () => { + throw new Error("not a URI"); + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => false } as any); + expect(resolveFileLinkUri("/some/directory")).toBeNull(); + (vscode.Uri as any).parse = origParse; + }); + + it("strips leading ./ from relative paths", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + // Should normalize path and not crash + expect(resolveFileLinkUri("./src/file.ts")).toBeNull(); + }); + + it("returns null for relative path with no workspace folders", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + expect(resolveFileLinkUri("src/file.ts")).toBeNull(); + }); + + it("resolves relative path against workspace folders", () => { + const origParse = vscode.Uri.parse; + (vscode.Uri as any).parse = () => { + throw new Error("not a URI"); + }; + const original = vscode.workspace.workspaceFolders; + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/workspace" } }, + ]; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); + + const result = resolveFileLinkUri("src/file.ts"); + expect(result).not.toBeNull(); + + (vscode.workspace as any).workspaceFolders = original; + (vscode.Uri as any).parse = origParse; + }); + + it("returns null when relative file not found in any workspace folder", () => { + const origParse = vscode.Uri.parse; + (vscode.Uri as any).parse = () => { + throw new Error("not a URI"); + }; + const original = vscode.workspace.workspaceFolders; + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/workspace" } }, + ]; + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(resolveFileLinkUri("nonexistent/file.ts")).toBeNull(); + + (vscode.workspace as any).workspaceFolders = original; + (vscode.Uri as any).parse = origParse; + }); + + it("resolves file:// URI scheme", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); + + const result = resolveFileLinkUri("file:///some/file.ts"); + expect(result).not.toBeNull(); + }); +}); diff --git a/tasksync-chat/src/webview/webviewUtils.ts b/tasksync-chat/src/webview/webviewUtils.ts new file mode 100644 index 0000000..ea66f21 --- /dev/null +++ b/tasksync-chat/src/webview/webviewUtils.ts @@ -0,0 +1,259 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { CONFIG_SECTION } from "../constants/remoteConstants"; +import { generateId } from "../utils/generateId"; +import type { P, ToolCallEntry } from "./webviewTypes"; + +export { generateId }; + +/** + * Debug log — only outputs when `tasksync.debugLogging` is enabled. + * Uses console.error to appear in the VS Code debug console. + */ +export function debugLog(...args: unknown[]): void { + if ( + vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get("debugLogging", false) + ) { + console.error("[TaskSync]", ...args); + } +} + +/** Returns true when the queue is enabled and has pending items. */ +export function hasQueuedItems(p: P): boolean { + return p._queueEnabled && p._promptQueue.length > 0; +} + +/** + * Merge two ToolCallEntry arrays, deduplicate by ID (first occurrence wins), + * sort by timestamp descending, and cap at maxEntries. + */ +export function mergeAndDedup( + primary: ToolCallEntry[], + secondary: ToolCallEntry[], + maxEntries: number, +): ToolCallEntry[] { + const seen = new Set(); + return [...primary, ...secondary] + .filter((entry) => { + if (seen.has(entry.id)) return false; + seen.add(entry.id); + return true; + }) + .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) + .slice(0, maxEntries); +} + +/** + * Increment queue version, persist, update UI, and broadcast to remote. + */ +export function notifyQueueChanged(p: P): void { + p._queueVersion++; + debugLog( + `[TaskSync] notifyQueueChanged — queueVersion: ${p._queueVersion}, queueSize: ${p._promptQueue.length}, queueEnabled: ${p._queueEnabled}`, + ); + p._saveQueueToDisk(); + p._updateQueueUI(); + p._remoteServer?.broadcast("queueChanged", { + queue: p._promptQueue.map((q) => ({ + ...q, + attachments: q.attachments || [], + })), + queueVersion: p._queueVersion, + }); +} + +/** + * Broadcast a toolCallCompleted event to remote clients. + */ +export function broadcastToolCallCompleted( + p: P, + entry: ToolCallEntry, + sessionTerminated?: boolean, +): void { + debugLog( + `[TaskSync] broadcastToolCallCompleted — id: ${entry.id}, status: ${entry.status}, response: "${(entry.response || "").slice(0, 60)}", summary: ${entry.summary ? `"${entry.summary.slice(0, 40)}"` : "(none)"}, sessionTerminated: ${!!sessionTerminated}`, + ); + p._remoteServer?.broadcast("toolCallCompleted", { + id: entry.id, + entry: { + id: entry.id, + prompt: entry.prompt, + summary: entry.summary, + response: entry.response, + timestamp: entry.timestamp, + status: entry.status, + attachments: entry.attachments, + isFromQueue: entry.isFromQueue, + }, + sessionTerminated: sessionTerminated || false, + }); +} + +/** + * Mark the current session as terminated and freeze the timer. + */ +export function markSessionTerminated(p: P): void { + debugLog( + `[TaskSync] markSessionTerminated — sessionStartTime: ${p._sessionStartTime}, aiTurnActive was: ${p._aiTurnActive}`, + ); + p._sessionTerminated = true; + p._aiTurnActive = false; + if (p._sessionStartTime !== null) { + p._sessionFrozenElapsed = Date.now() - p._sessionStartTime; + p._stopSessionTimerInterval(); + p._updateViewTitle(); + } +} + +/** + * Format milliseconds into a human-readable elapsed time string. + */ +export function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +/** + * Generate a random delay (jitter) between min and max seconds. + * Returns 0 if disabled. + */ +export function getHumanLikeDelayMs( + enabled: boolean, + minSec: number, + maxSec: number, +): number { + if (!enabled) { + return 0; + } + const minMs = minSec * 1000; + const maxMs = maxSec * 1000; + return Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; +} + +/** + * Generate a cryptographically secure nonce for CSP. + */ +export function getNonce(): string { + return crypto.randomBytes(16).toString("hex"); +} + +/** + * Get file icon based on extension. + */ +export function getFileIcon(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return FILE_ICON_MAP[ext] || "file"; +} + +/** Map file extensions to codicon icon names. */ +const FILE_ICON_MAP: Record = { + ts: "file-code", + tsx: "file-code", + js: "file-code", + jsx: "file-code", + py: "file-code", + java: "file-code", + c: "file-code", + cpp: "file-code", + html: "file-code", + css: "file-code", + scss: "file-code", + json: "json", + yaml: "file-code", + yml: "file-code", + md: "markdown", + txt: "file-text", + png: "file-media", + jpg: "file-media", + jpeg: "file-media", + gif: "file-media", + svg: "file-media", + sh: "terminal", + bash: "terminal", + ps1: "terminal", + zip: "file-zip", + tar: "file-zip", + gz: "file-zip", +}; + +/** + * Parse file link target in format "path#Lx" or "path#Lx-Ly". + */ +export function parseFileLinkTarget(target: string): { + filePath: string; + startLine: number | null; + endLine: number | null; +} { + const trimmedTarget = target.trim(); + const match = /^(.*?)(?:#L(\d+)(?:-L(\d+))?)?$/.exec(trimmedTarget); + const parseLine = (value: string | undefined): number | null => { + if (!value) { + return null; + } + const parsedValue = Number.parseInt(value, 10); + return Number.isFinite(parsedValue) ? parsedValue : null; + }; + + const filePath = (match?.[1] ?? trimmedTarget).trim(); + const startLine = parseLine(match?.[2]); + const endLine = parseLine(match?.[3]); + + return { filePath, startLine, endLine }; +} + +/** + * Resolve a file link path to an existing file URI. + */ +export function resolveFileLinkUri(rawPath: string): vscode.Uri | null { + const normalizedPath = rawPath.trim().replace(/^\.\//, "").trim(); + if (!normalizedPath) { + return null; + } + + try { + const parsedUri = vscode.Uri.parse(normalizedPath); + if ( + parsedUri.scheme === "file" && + fs.existsSync(parsedUri.fsPath) && + fs.statSync(parsedUri.fsPath).isFile() + ) { + return parsedUri; + } + } catch { + // Treat as path when parsing as URI fails. + } + + if (path.isAbsolute(normalizedPath)) { + if (fs.existsSync(normalizedPath) && fs.statSync(normalizedPath).isFile()) { + return vscode.Uri.file(path.resolve(normalizedPath)); + } + return null; + } + + const workspaceFolders = vscode.workspace.workspaceFolders || []; + if (workspaceFolders.length === 0) { + return null; + } + + for (const folder of workspaceFolders) { + const candidatePath = path.resolve(folder.uri.fsPath, normalizedPath); + if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) { + return vscode.Uri.file(candidatePath); + } + } + + return null; +} diff --git a/tasksync-chat/vitest.config.ts b/tasksync-chat/vitest.config.ts new file mode 100644 index 0000000..6782f32 --- /dev/null +++ b/tasksync-chat/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + alias: { + vscode: new URL("./src/__mocks__/vscode.ts", import.meta.url).pathname, + }, + coverage: { + provider: "v8", + include: [ + "src/constants/**/*.ts", + "src/utils/**/*.ts", + "src/webview/choiceParser.ts", + "src/webview/webviewUtils.ts", + "src/server/serverUtils.ts", + "src/server/gitService.ts", + "src/webview/queueHandlers.ts", + "src/webview/settingsHandlers.ts", + ], + exclude: ["src/**/*.test.ts", "src/__mocks__/**"], + }, + }, +}); diff --git a/tasksync-chat/web/icons/icon-192.svg b/tasksync-chat/web/icons/icon-192.svg new file mode 100644 index 0000000..6ccc403 --- /dev/null +++ b/tasksync-chat/web/icons/icon-192.svg @@ -0,0 +1,5 @@ + + + T + + diff --git a/tasksync-chat/web/icons/icon-512.svg b/tasksync-chat/web/icons/icon-512.svg new file mode 100644 index 0000000..e873c74 --- /dev/null +++ b/tasksync-chat/web/icons/icon-512.svg @@ -0,0 +1,5 @@ + + + T + + diff --git a/tasksync-chat/web/index.html b/tasksync-chat/web/index.html new file mode 100644 index 0000000..cb1feab --- /dev/null +++ b/tasksync-chat/web/index.html @@ -0,0 +1,63 @@ + + + + + + + + + + + TaskSync Remote + + + + + + +
    + + +

    TaskSync Remote

    +

    Enter the PIN shown in VS Code

    + +
    + + + + + + +
    + +

    + + + +
    +
    + Connecting... +
    + +
    +

    Connecting to server...

    +
    +
    + + + + + + + + \ No newline at end of file diff --git a/tasksync-chat/web/login.css b/tasksync-chat/web/login.css new file mode 100644 index 0000000..5cbdb89 --- /dev/null +++ b/tasksync-chat/web/login.css @@ -0,0 +1,164 @@ +/* ==================== Login Page Styles ==================== */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1e1e1e; + color: #d4d4d4; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; +} + +.container { + max-width: 320px; + width: 100%; + text-align: center; +} + +.logo { + width: 80px; + height: 80px; + margin-bottom: 20px; +} + +h1 { + font-size: 24px; + font-weight: 600; + margin-bottom: 8px; + color: #fff; +} + +.subtitle { + color: #808080; + margin-bottom: 32px; + font-size: 14px; +} + +.pin-input { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 24px; +} + +.pin-digit { + width: 48px; + height: 56px; + font-size: 24px; + text-align: center; + border: 2px solid #3c3c3c; + border-radius: 8px; + background: #252526; + color: #fff; + outline: none; + transition: border-color 0.2s; +} + +.pin-digit:focus { + border-color: #0078d4; +} + +.pin-digit.error { + border-color: #f14c4c; + animation: shake 0.3s; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + + 25% { + transform: translateX(-5px); + } + + 75% { + transform: translateX(5px); + } +} + +.error-message { + color: #f14c4c; + font-size: 14px; + margin-bottom: 16px; + min-height: 20px; +} + +.submit-btn { + width: 100%; + padding: 14px; + font-size: 16px; + font-weight: 600; + border: none; + border-radius: 8px; + background: #0078d4; + color: #fff; + cursor: pointer; + transition: background 0.2s; +} + +.submit-btn:hover { + background: #1e8ad4; +} + +.submit-btn:disabled { + background: #3c3c3c; + color: #808080; + cursor: not-allowed; +} + +.connecting { + display: none; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 16px; + color: #d4d4d4; + font-size: 14px; +} + +.connecting.visible { + display: flex; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #3c3c3c; + border-top-color: #0078d4; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.no-pin { + display: none; +} + +.no-pin.visible { + display: block; +} + +.pin-digit-extra { + opacity: 0.4; +} + +.pin-digit-extra.active { + opacity: 1; +} diff --git a/tasksync-chat/web/login.js b/tasksync-chat/web/login.js new file mode 100644 index 0000000..5e87e99 --- /dev/null +++ b/tasksync-chat/web/login.js @@ -0,0 +1,313 @@ +// ==================== Login Page Script ==================== +// Handles PIN input, WebSocket auth, and session management for login page. + +const LOGIN_CONNECT_TIMEOUT_MS = 10000; // WebSocket connection timeout +const SESSION_KEYS = TASKSYNC_SESSION_KEYS; // Reference shared SSOT constant +const getWsProtocol = getTaskSyncWsProtocol; // Reference shared SSOT helper + +// Register service worker for PWA support (caching, offline, install) +if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("./sw.js") + .then((reg) => { + reg.addEventListener("updatefound", () => { + const nw = reg.installing; + if (nw) + nw.addEventListener("statechange", () => { + if ( + nw.state === "activated" && + navigator.serviceWorker.controller + ) { + // New service worker activated — user will get update on next reload + } + }); + }); + }) + .catch((err) => console.error("[TaskSync] SW registration failed:", err)); +} + +const digits = document.querySelectorAll(".pin-digit"); +const submitBtn = document.getElementById("submit"); +const errorEl = document.getElementById("error"); +const connectingEl = document.getElementById("connecting"); + +let ws = null; + +/** Check protocol version; warn if mismatched */ +function checkProtocolVersion(msg) { + if ( + msg.protocolVersion !== undefined && + msg.protocolVersion !== TASKSYNC_PROTOCOL_VERSION + ) { + console.error( + "[TaskSync] Protocol version mismatch: server=" + + msg.protocolVersion + + " client=" + + TASKSYNC_PROTOCOL_VERSION, + ); + } +} + +// Handle successful auth (SSOT) +function handleAuthSuccess(state, pin, sessionToken) { + sessionStorage.setItem(SESSION_KEYS.STATE, JSON.stringify(state)); + sessionStorage.setItem(SESSION_KEYS.CONNECTED, "true"); + if (sessionToken) { + sessionStorage.setItem(SESSION_KEYS.SESSION_TOKEN, sessionToken); + sessionStorage.removeItem(SESSION_KEYS.PIN); + } else if (pin) { + sessionStorage.setItem(SESSION_KEYS.PIN, pin); + } + window.location.href = "app.html"; +} + +// Auto-focus first digit (if visible) +digits[0]?.focus(); + +// Handle digit input +digits.forEach((input, i) => { + input.addEventListener("input", (e) => { + const value = e.target.value.replace(/\D/g, ""); + e.target.value = value.slice(-1); + + if (value && i < digits.length - 1) { + digits[i + 1].focus(); + } + + updateSubmitState(); + clearError(); + }); + + input.addEventListener("keydown", (e) => { + if (e.key === "Backspace" && !e.target.value && i > 0) { + digits[i - 1].focus(); + } + if (e.key === "Enter") { + attemptConnect(); + } + }); + + input.addEventListener("paste", (e) => { + e.preventDefault(); + const pasted = (e.clipboardData.getData("text") || "") + .replace(/\D/g, "") + .slice(0, 6); + pasted.split("").forEach((char, j) => { + if (digits[j]) digits[j].value = char; + }); + if (pasted.length > 0) { + digits[Math.min(pasted.length, digits.length - 1)].focus(); + } + updateSubmitState(); + }); +}); + +function updateSubmitState() { + const pin = getPin(); + submitBtn.disabled = pin.length < 4; + + // Reveal extra PIN digit inputs as needed + const extras = document.querySelectorAll(".pin-digit-extra"); + extras.forEach((el, j) => { + const threshold = 4 + j; // 5th digit needs 4 filled, 6th needs 5 filled + el.classList.toggle("active", pin.length >= threshold); + }); +} + +function getPin() { + return Array.from(digits) + .map((d) => d.value) + .join(""); +} + +function setError(msg) { + errorEl.textContent = msg; + digits.forEach((d) => d.classList.add("error")); + setTimeout(() => digits.forEach((d) => d.classList.remove("error")), 300); +} + +function clearError() { + errorEl.textContent = ""; +} + +function setConnecting(loading) { + connectingEl.classList.toggle("visible", loading); + if (loading) { + submitBtn.disabled = true; + } else { + updateSubmitState(); + } +} + +submitBtn.addEventListener("click", attemptConnect); + +function attemptConnect() { + const pin = getPin(); + if (pin.length < 4) return; + + // Close any previous connection + if (ws) { + try { + ws.close(); + } catch { } + ws = null; + } + + setConnecting(true); + clearError(); + + // Connection timeout — don't leave user stuck forever + const connectTimeout = setTimeout(() => { + if (ws && ws.readyState !== WebSocket.OPEN) { + try { + ws.close(); + } catch { } + setConnecting(false); + setError("Connection timed out"); + } + }, LOGIN_CONNECT_TIMEOUT_MS); + + // Connect WebSocket + ws = new WebSocket(`${getWsProtocol()}//${location.host}`); + + ws.onopen = () => { + clearTimeout(connectTimeout); + ws.send(JSON.stringify({ type: "auth", pin })); + }; + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + checkProtocolVersion(msg); + + if (msg.type === "authSuccess") { + // Close login WebSocket before redirect to free MAX_CLIENTS slot + ws.close(); + // Store state, PIN and session token, then redirect to app + handleAuthSuccess(msg.state, pin, msg.sessionToken); + } else if (msg.type === "authFailed") { + setConnecting(false); + setError(msg.message || "Invalid PIN"); + digits.forEach((d) => (d.value = "")); + digits[0].focus(); + updateSubmitState(); + } else if (msg.type === "connected") { + // No PIN required + ws.close(); + handleAuthSuccess(msg.state, null, null); + } else if (msg.type === "error") { + // Server error during auth + setConnecting(false); + setError(msg.message || "Server error"); + } + // Ignore 'requireAuth' — we already sent PIN, waiting for auth response + } catch { + setConnecting(false); + setError("Connection error"); + } + }; + + ws.onerror = () => { + clearTimeout(connectTimeout); + setConnecting(false); + setError("Connection failed"); + }; + + ws.onclose = () => { + clearTimeout(connectTimeout); + if (connectingEl.classList.contains("visible")) { + setConnecting(false); + setError("Connection closed"); + } + }; +} + +// Check for PIN in URL fragment (#pin=XXXX) — fragments are NOT sent to the server. +// Clear PIN from URL immediately after reading to minimize exposure. +const hashParams = new URLSearchParams(window.location.hash.slice(1)); +const urlPin = hashParams.get("pin"); +if (urlPin && urlPin.length >= 4) { + // Clear PIN from URL + window.history.replaceState({}, "", window.location.pathname); + // Fill PIN inputs with URL PIN + const pinDigits = urlPin.slice(0, 6).split(""); + pinDigits.forEach((char, i) => { + if (digits[i]) digits[i].value = char; + }); + updateSubmitState(); + // Auto-authenticate + attemptConnect(); +} else { + // Try connecting to check if PIN is required, or auto-login with stored credentials + (function checkAuth() { + const sessionToken = + sessionStorage.getItem(SESSION_KEYS.SESSION_TOKEN) || ""; + const storedPin = sessionStorage.getItem(SESSION_KEYS.PIN) || ""; + const hasCredentials = !!(sessionToken || storedPin); + + const checkWs = new WebSocket(`${getWsProtocol()}//${location.host}`); + + // Timeout for auto-check + const checkTimeout = setTimeout(() => { + try { + checkWs.close(); + } catch { } + }, LOGIN_CONNECT_TIMEOUT_MS); + + if (hasCredentials) { + checkWs.onopen = () => { + checkWs.send( + JSON.stringify({ + type: "auth", + pin: storedPin, + sessionToken: sessionToken, + }), + ); + }; + } + + checkWs.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + checkProtocolVersion(msg); + if (msg.type === "connected") { + // No PIN required, go to app + clearTimeout(checkTimeout); + handleAuthSuccess(msg.state, null, null); + checkWs.close(); + return; + } + if (msg.type === "authSuccess") { + // Stored credentials worked — auto-login + clearTimeout(checkTimeout); + handleAuthSuccess(msg.state, storedPin || null, msg.sessionToken); + checkWs.close(); + return; + } + if (msg.type === "authFailed" || msg.type === "error") { + // Stored credentials invalid — clear and show PIN input + clearTimeout(checkTimeout); + sessionStorage.removeItem(SESSION_KEYS.SESSION_TOKEN); + sessionStorage.removeItem(SESSION_KEYS.PIN); + checkWs.close(); + return; + } + if (msg.type === "requireAuth" && !hasCredentials) { + // No stored credentials — PIN input already visible + clearTimeout(checkTimeout); + checkWs.close(); + return; + } + // If requireAuth and hasCredentials, wait for auth response + } catch { + clearTimeout(checkTimeout); + checkWs.close(); + } + }; + + checkWs.onerror = () => { + clearTimeout(checkTimeout); + checkWs.close(); + }; + })(); +} diff --git a/tasksync-chat/web/manifest.json b/tasksync-chat/web/manifest.json new file mode 100644 index 0000000..cf0536e --- /dev/null +++ b/tasksync-chat/web/manifest.json @@ -0,0 +1,25 @@ +{ + "id": "tasksync-remote", + "name": "TaskSync Remote", + "short_name": "TaskSync", + "description": "Control TaskSync from your browser", + "start_url": "./", + "display": "standalone", + "background_color": "#1e1e1e", + "theme_color": "#0078d4", + "orientation": "any", + "icons": [ + { + "src": "icons/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "icons/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/tasksync-chat/web/offline.html b/tasksync-chat/web/offline.html new file mode 100644 index 0000000..7d94882 --- /dev/null +++ b/tasksync-chat/web/offline.html @@ -0,0 +1,62 @@ + + + + + + + + TaskSync — Offline + + + + +
    +

    You're offline

    +

    TaskSync needs a network connection to reach the server. Check your connection and try again.

    + +
    + + + \ No newline at end of file diff --git a/tasksync-chat/web/shared-constants.js b/tasksync-chat/web/shared-constants.js new file mode 100644 index 0000000..c097b76 --- /dev/null +++ b/tasksync-chat/web/shared-constants.js @@ -0,0 +1,44 @@ +/** + * AUTO-GENERATED from src/constants/remoteConstants.ts — DO NOT EDIT MANUALLY + * Run `node esbuild.js` to regenerate. + * + * Shared constants for TaskSync web frontend (SSOT) + * Used by both index.html (login page) and webview.js (app) + * Include this file BEFORE index.html inline scripts or webview.js + */ + +// Session storage keys +var TASKSYNC_SESSION_KEYS = { + STATE: 'taskSyncState', + PIN: 'taskSyncPin', + CONNECTED: 'taskSyncConnected', + SESSION_TOKEN: 'taskSyncSessionToken' +}; + +// WebSocket protocol helper +function getTaskSyncWsProtocol() { + return location.protocol === 'https:' ? 'wss:' : 'ws:'; +} + +// Reconnection settings +var TASKSYNC_MAX_RECONNECT_ATTEMPTS = 20; +var TASKSYNC_MAX_RECONNECT_DELAY_MS = 30000; + +// Protocol version (from WS_PROTOCOL_VERSION) +var TASKSYNC_PROTOCOL_VERSION = 1; + +// Response timeout settings (from RESPONSE_TIMEOUT_ALLOWED_VALUES, RESPONSE_TIMEOUT_DEFAULT_MINUTES) +var TASKSYNC_RESPONSE_TIMEOUT_ALLOWED = [0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 150, 180, 210, 240]; +var TASKSYNC_RESPONSE_TIMEOUT_DEFAULT = 60; + +// Settings defaults & validation ranges (from remoteConstants.ts) +var TASKSYNC_DEFAULT_SESSION_WARNING_HOURS = 2; +var TASKSYNC_SESSION_WARNING_HOURS_MAX = 8; +var TASKSYNC_DEFAULT_MAX_AUTO_RESPONSES = 5; +var TASKSYNC_MAX_AUTO_RESPONSES_LIMIT = 100; +var TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MIN = 2; +var TASKSYNC_DEFAULT_HUMAN_LIKE_DELAY_MAX = 6; +var TASKSYNC_HUMAN_DELAY_MIN_LOWER = 1; +var TASKSYNC_HUMAN_DELAY_MIN_UPPER = 30; +var TASKSYNC_HUMAN_DELAY_MAX_LOWER = 2; +var TASKSYNC_HUMAN_DELAY_MAX_UPPER = 60; diff --git a/tasksync-chat/web/sw.js b/tasksync-chat/web/sw.js new file mode 100644 index 0000000..7bae63d --- /dev/null +++ b/tasksync-chat/web/sw.js @@ -0,0 +1,87 @@ +// TaskSync Remote - Service Worker +const CACHE_NAME = 'tasksync-remote-v5'; +const STATIC_ASSETS = [ + './', + './index.html', + './offline.html', + './login.css', + './login.js', + './shared-constants.js', + './manifest.json', + './icons/icon-192.svg', + './icons/icon-512.svg' +]; + +// Install +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +// Activate +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// Fetch - Network first, fallback to cache (static assets only) +self.addEventListener('fetch', (e) => { + // Skip non-http(s) URLs (e.g., chrome-extension://, blob:, data:) + if (!e.request.url.startsWith('http://') && !e.request.url.startsWith('https://')) { + return; + } + // Skip WebSocket requests + if (e.request.url.includes('ws://') || e.request.url.includes('wss://')) { + return; + } + + const url = new URL(e.request.url); + + // Don't cache API responses (may contain sensitive data) + if (url.pathname.startsWith('/api/')) { + e.respondWith(fetch(e.request)); + return; + } + + const normalizedStaticAssets = STATIC_ASSETS + .map((asset) => asset.replace(/^\.\//, '')) + .filter((asset) => asset.length > 0) + .map((asset) => '/' + asset.replace(/^\/+/, '')); + const trustedStaticPaths = new Set(normalizedStaticAssets); + const isPrecachedStaticAsset = trustedStaticPaths.has(url.pathname); + + // Don't cache JS files at runtime — only precached static assets in STATIC_ASSETS + // are trusted. This prevents caching potentially tampered scripts over HTTP. + if (url.pathname.endsWith('.js') && !isPrecachedStaticAsset) { + e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); + return; + } + + e.respondWith( + fetch(e.request) + .then((response) => { + // Clone and cache successful responses + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(e.request, clone)); + } + return response; + }) + .catch(() => + caches.match(e.request).then((cached) => { + if (cached) return cached; + // For navigation requests, show offline page + if (e.request.mode === 'navigate') { + return caches.match('./offline.html'); + } + return cached; // undefined — browser default error + }) + ) + ); +}); From 844257dbcd18f500a69cfceffbf51d289071c1be Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 01:35:05 +0400 Subject: [PATCH 02/35] refactor: Clean up formatting in tool call handling and webview types --- tasksync-chat/src/webview/toolCallHandler.ts | 12 +-- tasksync-chat/src/webview/webviewProvider.ts | 5 +- tasksync-chat/src/webview/webviewTypes.ts | 102 +++++++++---------- 3 files changed, 59 insertions(+), 60 deletions(-) diff --git a/tasksync-chat/src/webview/toolCallHandler.ts b/tasksync-chat/src/webview/toolCallHandler.ts index 6e4e6e1..e9a39b7 100644 --- a/tasksync-chat/src/webview/toolCallHandler.ts +++ b/tasksync-chat/src/webview/toolCallHandler.ts @@ -321,12 +321,12 @@ export async function waitForUserResponse( choices: choices.length > 0 ? choices.map( - (c: { label: string; value: string; shortLabel?: string }) => ({ - label: c.label, - value: c.value, - shortLabel: c.shortLabel, - }), - ) + (c: { label: string; value: string; shortLabel?: string }) => ({ + label: c.label, + value: c.value, + shortLabel: c.shortLabel, + }), + ) : undefined, }); diff --git a/tasksync-chat/src/webview/webviewProvider.ts b/tasksync-chat/src/webview/webviewProvider.ts index d32a1d9..929cb57 100644 --- a/tasksync-chat/src/webview/webviewProvider.ts +++ b/tasksync-chat/src/webview/webviewProvider.ts @@ -36,12 +36,11 @@ export type { QueuedPrompt, ReusablePrompt, ToolCallEntry, - UserResponseResult, + UserResponseResult } from "./webviewTypes"; export class TaskSyncWebviewProvider - implements vscode.WebviewViewProvider, vscode.Disposable -{ + implements vscode.WebviewViewProvider, vscode.Disposable { public static readonly viewType = VIEW_TYPE; // All underscore-prefixed members are "internal" by convention but public diff --git a/tasksync-chat/src/webview/webviewTypes.ts b/tasksync-chat/src/webview/webviewTypes.ts index d5a151d..ebb5c30 100644 --- a/tasksync-chat/src/webview/webviewTypes.ts +++ b/tasksync-chat/src/webview/webviewTypes.ts @@ -76,18 +76,18 @@ export interface ReusablePrompt { export type ToWebviewMessage = | { type: "updateQueue"; queue: QueuedPrompt[]; enabled: boolean } | { - type: "toolCallPending"; - id: string; - prompt: string; - summary?: string; - isApproval: boolean; - choices?: ParsedChoice[]; - } + type: "toolCallPending"; + id: string; + prompt: string; + summary?: string; + isApproval: boolean; + choices?: ParsedChoice[]; + } | { - type: "toolCallCompleted"; - entry: ToolCallEntry; - sessionTerminated?: boolean; - } + type: "toolCallCompleted"; + entry: ToolCallEntry; + sessionTerminated?: boolean; + } | { type: "updateCurrentSession"; history: ToolCallEntry[] } | { type: "updatePersistedHistory"; history: ToolCallEntry[] } | { type: "fileSearchResults"; files: FileSearchResult[] } @@ -95,43 +95,43 @@ export type ToWebviewMessage = | { type: "imageSaved"; attachment: AttachmentInfo } | { type: "openSettingsModal" } | { - type: "updateSettings"; - soundEnabled: boolean; - interactiveApprovalEnabled: boolean; - autopilotEnabled: boolean; - autopilotText: string; - autopilotPrompts: string[]; - reusablePrompts: ReusablePrompt[]; - responseTimeout: number; - sessionWarningHours: number; - maxConsecutiveAutoResponses: number; - humanLikeDelayEnabled: boolean; - humanLikeDelayMin: number; - humanLikeDelayMax: number; - sendWithCtrlEnter: boolean; - queueEnabled: boolean; - } + type: "updateSettings"; + soundEnabled: boolean; + interactiveApprovalEnabled: boolean; + autopilotEnabled: boolean; + autopilotText: string; + autopilotPrompts: string[]; + reusablePrompts: ReusablePrompt[]; + responseTimeout: number; + sessionWarningHours: number; + maxConsecutiveAutoResponses: number; + humanLikeDelayEnabled: boolean; + humanLikeDelayMin: number; + humanLikeDelayMax: number; + sendWithCtrlEnter: boolean; + queueEnabled: boolean; + } | { type: "slashCommandResults"; prompts: ReusablePrompt[] } | { type: "playNotificationSound" } | { - type: "contextSearchResults"; - suggestions: Array<{ - type: string; - label: string; - description: string; - detail: string; - }>; - } + type: "contextSearchResults"; + suggestions: Array<{ + type: string; + label: string; + description: string; + detail: string; + }>; + } | { - type: "contextReferenceAdded"; - reference: { id: string; type: string; label: string; content: string }; - } + type: "contextReferenceAdded"; + reference: { id: string; type: string; label: string; content: string }; + } | { type: "clear" } | { - type: "updateSessionTimer"; - startTime: number | null; - frozenElapsed: number | null; - } + type: "updateSessionTimer"; + startTime: number | null; + frozenElapsed: number | null; + } | { type: "triggerSendFromShortcut" } | { type: "openHistoryModal" }; @@ -139,11 +139,11 @@ export type ToWebviewMessage = export type FromWebviewMessage = | { type: "submit"; value: string; attachments: AttachmentInfo[] } | { - type: "addQueuePrompt"; - prompt: string; - id: string; - attachments?: AttachmentInfo[]; - } + type: "addQueuePrompt"; + prompt: string; + id: string; + attachments?: AttachmentInfo[]; + } | { type: "removeQueuePrompt"; promptId: string } | { type: "editQueuePrompt"; promptId: string; newPrompt: string } | { type: "reorderQueue"; fromIndex: number; toIndex: number } @@ -183,8 +183,8 @@ export type FromWebviewMessage = | { type: "updateSendWithCtrlEnterSetting"; enabled: boolean } | { type: "searchContext"; query: string } | { - type: "selectContextReference"; - contextType: string; - options?: Record; - } + type: "selectContextReference"; + contextType: string; + options?: Record; + } | { type: "copyToClipboard"; text: string }; From 167b6e24b1f28fe956690951de65a40780b0798a Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 01:50:00 +0400 Subject: [PATCH 03/35] fix: address Copilot PR review feedback - Remove leftover console.log statements from webview-ui messageHandler - Fix queue.json backward compat (enabled defaults to true when missing) - Fix queueHandlers: missing resolver falls through to queue instead of dropping the prompt; set _aiTurnActive after auto-respond resolve - Fix serverUtils cert SAN: detect IP vs DNS hostname for correct type - Fix gitService: normalize backslashes for Windows path validation - Fix messageRouter: preserve attachments in queue fallback path - Fix remoteAuthService: implement query-param PIN support (matches docs) - Fix incomplete test in queueHandlers.test.ts (add assertion) - Update tests to match new behavior --- tasksync-chat/media/webview.js | 23 ---- tasksync-chat/src/server/gitService.test.ts | 5 +- tasksync-chat/src/server/gitService.ts | 12 +- .../src/server/remoteAuthService.test.ts | 4 +- tasksync-chat/src/server/remoteAuthService.ts | 3 +- tasksync-chat/src/server/serverUtils.ts | 6 +- .../src/webview-ui/messageHandler.js | 23 ---- tasksync-chat/src/webview/messageRouter.ts | 1 + tasksync-chat/src/webview/persistence.ts | 2 +- .../queueHandlers.comprehensive.test.ts | 5 +- .../src/webview/queueHandlers.test.ts | 3 + tasksync-chat/src/webview/queueHandlers.ts | 109 ++++++++++-------- tasksync-chat/src/webview/toolCallHandler.ts | 12 +- tasksync-chat/src/webview/webviewProvider.ts | 5 +- tasksync-chat/src/webview/webviewTypes.ts | 102 ++++++++-------- 15 files changed, 148 insertions(+), 167 deletions(-) diff --git a/tasksync-chat/media/webview.js b/tasksync-chat/media/webview.js index 651c874..2d8275d 100644 --- a/tasksync-chat/media/webview.js +++ b/tasksync-chat/media/webview.js @@ -2333,7 +2333,6 @@ function handleExtensionMessage(event) { } break; case "clear": - console.log("[TaskSync Webview] clear — resetting session state"); promptQueue = []; currentSessionCalls = []; pendingToolCall = null; @@ -2360,16 +2359,6 @@ function handleExtensionMessage(event) { } function showPendingToolCall(id, prompt, isApproval, choices, summary) { - console.log( - "[TaskSync Webview] showPendingToolCall — id:", - id, - "hasSummary:", - !!summary, - "summaryLength:", - summary ? summary.length : 0, - "promptLength:", - prompt ? prompt.length : 0, - ); pendingToolCall = { id: id, prompt: prompt, summary: summary || "" }; isProcessingResponse = false; // AI is now asking, not processing isApprovalQuestion = isApproval === true; @@ -2387,23 +2376,11 @@ function showPendingToolCall(id, prompt, isApproval, choices, summary) { pendingMessage.classList.remove("hidden"); let pendingHtml = ""; if (summary) { - console.log( - "[TaskSync Webview] Rendering summary in pending view — length:", - summary.length, - "preview:", - summary.slice(0, 80), - ); pendingHtml += '
    ' + formatMarkdown(summary) + "
    "; - } else { - console.log("[TaskSync Webview] No summary to render in pending view"); } pendingHtml += '
    ' + formatMarkdown(prompt) + "
    "; - console.log( - "[TaskSync Webview] Pending HTML set — totalLength:", - pendingHtml.length, - ); pendingMessage.innerHTML = pendingHtml; } else { console.error("[TaskSync Webview] pendingMessage element is null!"); diff --git a/tasksync-chat/src/server/gitService.test.ts b/tasksync-chat/src/server/gitService.test.ts index 952c0b5..25c8884 100644 --- a/tasksync-chat/src/server/gitService.test.ts +++ b/tasksync-chat/src/server/gitService.test.ts @@ -59,8 +59,9 @@ describe("isValidFilePath", () => { expect(isValidFilePath("file'name")).toBe(false); }); - it("rejects paths with backslash", () => { - expect(isValidFilePath("file\\name")).toBe(false); + it("allows paths with backslash (Windows paths)", () => { + expect(isValidFilePath("file\\name")).toBe(true); + expect(isValidFilePath("src\\app.ts")).toBe(true); }); it("rejects paths with null bytes", () => { diff --git a/tasksync-chat/src/server/gitService.ts b/tasksync-chat/src/server/gitService.ts index 8a6aa82..8dd4ae7 100644 --- a/tasksync-chat/src/server/gitService.ts +++ b/tasksync-chat/src/server/gitService.ts @@ -47,12 +47,14 @@ export interface GitChanges { export function isValidFilePath(filePath: string): boolean { // Reject empty/whitespace-only paths if (!filePath || !filePath.trim()) return false; - // Reject paths with shell metacharacters - const dangerousChars = /[`$|;&<>(){}[\]!*?\\'"\n\r\x00]/; - if (dangerousChars.test(filePath)) return false; + // Normalize separators so Windows-style paths are handled consistently + const normalizedPath = filePath.replace(/\\/g, "/"); + // Reject paths with shell metacharacters (allow path separators) + const dangerousChars = /[`$|;&<>(){}[\]!*?'"\n\r\x00]/; + if (dangerousChars.test(normalizedPath)) return false; // Reject absolute paths that escape workspace - if (filePath.includes("..")) { - const normalized = path.normalize(filePath); + if (normalizedPath.includes("..")) { + const normalized = path.normalize(normalizedPath); if (normalized.startsWith("..")) return false; } return true; diff --git a/tasksync-chat/src/server/remoteAuthService.test.ts b/tasksync-chat/src/server/remoteAuthService.test.ts index ef211c5..c71603f 100644 --- a/tasksync-chat/src/server/remoteAuthService.test.ts +++ b/tasksync-chat/src/server/remoteAuthService.test.ts @@ -289,13 +289,13 @@ describe("verifyHttpAuth", () => { expect(result.allowed).toBe(false); }); - it("no longer accepts PIN via query string", () => { + it("accepts PIN via query string", () => { const { svc } = createService("654321"); const req = createMockReq(); const url = new URL("http://localhost/api/test?pin=654321"); const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); + expect(result.allowed).toBe(true); }); it("locks out after repeated failures", () => { diff --git a/tasksync-chat/src/server/remoteAuthService.ts b/tasksync-chat/src/server/remoteAuthService.ts index cf5eeb1..d7aa0fd 100644 --- a/tasksync-chat/src/server/remoteAuthService.ts +++ b/tasksync-chat/src/server/remoteAuthService.ts @@ -242,7 +242,8 @@ export class RemoteAuthService { } const headerPin = req.headers["x-tasksync-pin"] as string | undefined; - const suppliedPin = headerPin || ""; + const queryPin = url.searchParams.get("pin") ?? undefined; + const suppliedPin = headerPin ?? queryPin ?? ""; const valid = this.comparePinTimingSafe(suppliedPin); diff --git a/tasksync-chat/src/server/serverUtils.ts b/tasksync-chat/src/server/serverUtils.ts index c89fc7a..b649c49 100644 --- a/tasksync-chat/src/server/serverUtils.ts +++ b/tasksync-chat/src/server/serverUtils.ts @@ -171,11 +171,15 @@ export async function generateSelfSignedCert(host: string): Promise { const notBefore = new Date(); const notAfter = new Date(); notAfter.setFullYear(notAfter.getFullYear() + 1); + const isIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":"); + const altNames = isIP + ? [{ type: 7 as const, ip: host }] + : [{ type: 2 as const, value: host }]; const pems = await selfsigned.generate(attrs, { keySize: 2048, notBeforeDate: notBefore, notAfterDate: notAfter, - extensions: [{ name: "subjectAltName", altNames: [{ type: 7, ip: host }] }], + extensions: [{ name: "subjectAltName", altNames }], }); return { key: pems.private, cert: pems.cert }; } diff --git a/tasksync-chat/src/webview-ui/messageHandler.js b/tasksync-chat/src/webview-ui/messageHandler.js index 3bcb75e..0c24a3e 100644 --- a/tasksync-chat/src/webview-ui/messageHandler.js +++ b/tasksync-chat/src/webview-ui/messageHandler.js @@ -108,7 +108,6 @@ function handleExtensionMessage(event) { } break; case "clear": - console.log("[TaskSync Webview] clear — resetting session state"); promptQueue = []; currentSessionCalls = []; pendingToolCall = null; @@ -135,16 +134,6 @@ function handleExtensionMessage(event) { } function showPendingToolCall(id, prompt, isApproval, choices, summary) { - console.log( - "[TaskSync Webview] showPendingToolCall — id:", - id, - "hasSummary:", - !!summary, - "summaryLength:", - summary ? summary.length : 0, - "promptLength:", - prompt ? prompt.length : 0, - ); pendingToolCall = { id: id, prompt: prompt, summary: summary || "" }; isProcessingResponse = false; // AI is now asking, not processing isApprovalQuestion = isApproval === true; @@ -162,23 +151,11 @@ function showPendingToolCall(id, prompt, isApproval, choices, summary) { pendingMessage.classList.remove("hidden"); let pendingHtml = ""; if (summary) { - console.log( - "[TaskSync Webview] Rendering summary in pending view — length:", - summary.length, - "preview:", - summary.slice(0, 80), - ); pendingHtml += '
    ' + formatMarkdown(summary) + "
    "; - } else { - console.log("[TaskSync Webview] No summary to render in pending view"); } pendingHtml += '
    ' + formatMarkdown(prompt) + "
    "; - console.log( - "[TaskSync Webview] Pending HTML set — totalLength:", - pendingHtml.length, - ); pendingMessage.innerHTML = pendingHtml; } else { console.error("[TaskSync Webview] pendingMessage element is null!"); diff --git a/tasksync-chat/src/webview/messageRouter.ts b/tasksync-chat/src/webview/messageRouter.ts index 22dcd9c..672bfc7 100644 --- a/tasksync-chat/src/webview/messageRouter.ts +++ b/tasksync-chat/src/webview/messageRouter.ts @@ -335,6 +335,7 @@ export function handleSubmit( const queuedPrompt: QueuedPrompt = { id: generateId("q"), prompt: value.trim(), + attachments: attachments.length > 0 ? [...attachments] : undefined, }; p._promptQueue.push(queuedPrompt); // Auto-switch to queue mode so user sees their message went to queue diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index c16ace8..c9cf88b 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -30,7 +30,7 @@ export async function loadQueueFromDiskAsync(p: P): Promise { const data = await fs.promises.readFile(queuePath, "utf8"); const parsed = JSON.parse(data); p._promptQueue = Array.isArray(parsed.queue) ? parsed.queue : []; - p._queueEnabled = parsed.enabled === true; + p._queueEnabled = parsed.enabled !== false; } catch (error) { console.error("[TaskSync] Failed to load queue:", error); p._promptQueue = []; diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index 335805a..4f00a21 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -215,8 +215,9 @@ describe("handleAddQueuePrompt", () => { }); handleAddQueuePrompt(p, "Task", "q_1_abc", []); - // Should not add to queue (shouldAutoRespond was true, but resolve was falsy) - expect(p._promptQueue).toHaveLength(0); + // Should fall through to queue since resolve was falsy (prompt not lost) + expect(p._promptQueue).toHaveLength(1); + expect(p._promptQueue[0].prompt).toBe("Task"); }); }); diff --git a/tasksync-chat/src/webview/queueHandlers.test.ts b/tasksync-chat/src/webview/queueHandlers.test.ts index 6c682f1..8b200d3 100644 --- a/tasksync-chat/src/webview/queueHandlers.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.test.ts @@ -75,6 +75,9 @@ describe("handleEditQueuePrompt", () => { it("rejects excessively long prompts", () => { const p = createMockP([{ id: ID1, prompt: "Keep" }]); const longPrompt = "x".repeat(100001); + handleEditQueuePrompt(p, ID1, longPrompt); + expect(p._promptQueue[0].prompt).toBe("Keep"); + expect(p._saveQueueToDisk).not.toHaveBeenCalled(); }); it("does nothing for non-existent ID", () => { diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index 28dbe04..e295411 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -50,62 +50,75 @@ export function handleAddQueuePrompt( const shouldAutoRespond = p._queueEnabled && currentCallId && p._pendingRequests.has(currentCallId); + let handledAsToolResponse = false; + if (shouldAutoRespond) { debugLog( `[TaskSync] handleAddQueuePrompt — auto-responding to pending tool call: ${currentCallId}`, ); const resolve = p._pendingRequests.get(currentCallId); - if (!resolve) return; - - const pendingEntry = p._currentSessionCallsMap.get(currentCallId); - - let completedEntry: ToolCallEntry; - if (pendingEntry && pendingEntry.status === "pending") { - pendingEntry.response = queuedPrompt.prompt; - pendingEntry.attachments = queuedPrompt.attachments; - pendingEntry.status = "completed"; - pendingEntry.isFromQueue = true; - pendingEntry.timestamp = Date.now(); - completedEntry = pendingEntry; + if (!resolve) { + // Inconsistent state: pending request id without a resolver. Clean up and fall through to queue. + debugLog( + `[TaskSync] handleAddQueuePrompt — missing resolver for pending tool call ${currentCallId}, falling back to queue`, + ); + p._pendingRequests.delete(currentCallId); + p._currentToolCallId = null; } else { - completedEntry = { - id: currentCallId, - prompt: "Tool call", - response: queuedPrompt.prompt, - attachments: queuedPrompt.attachments, - timestamp: Date.now(), - isFromQueue: true, - status: "completed", - }; - p._currentSessionCalls.unshift(completedEntry); - p._currentSessionCallsMap.set(completedEntry.id, completedEntry); - } - - p._view?.webview.postMessage({ - type: "toolCallCompleted", - entry: completedEntry, - } satisfies ToWebviewMessage); - - p._updateCurrentSessionUI(); - p._saveQueueToDisk(); - p._updateQueueUI(); - - broadcastToolCallCompleted(p, completedEntry); - - // Clear response timeout timer (matches resolveRemoteResponse behavior) - if (p._responseTimeoutTimer) { - clearTimeout(p._responseTimeoutTimer); - p._responseTimeoutTimer = null; + const pendingEntry = p._currentSessionCallsMap.get(currentCallId); + + let completedEntry: ToolCallEntry; + if (pendingEntry && pendingEntry.status === "pending") { + pendingEntry.response = queuedPrompt.prompt; + pendingEntry.attachments = queuedPrompt.attachments; + pendingEntry.status = "completed"; + pendingEntry.isFromQueue = true; + pendingEntry.timestamp = Date.now(); + completedEntry = pendingEntry; + } else { + completedEntry = { + id: currentCallId, + prompt: "Tool call", + response: queuedPrompt.prompt, + attachments: queuedPrompt.attachments, + timestamp: Date.now(), + isFromQueue: true, + status: "completed", + }; + p._currentSessionCalls.unshift(completedEntry); + p._currentSessionCallsMap.set(completedEntry.id, completedEntry); + } + + p._view?.webview.postMessage({ + type: "toolCallCompleted", + entry: completedEntry, + } satisfies ToWebviewMessage); + + p._updateCurrentSessionUI(); + p._saveQueueToDisk(); + p._updateQueueUI(); + + broadcastToolCallCompleted(p, completedEntry); + + // Clear response timeout timer (matches resolveRemoteResponse behavior) + if (p._responseTimeoutTimer) { + clearTimeout(p._responseTimeoutTimer); + p._responseTimeoutTimer = null; + } + + resolve({ + value: queuedPrompt.prompt, + queue: hasQueuedItems(p), + attachments: queuedPrompt.attachments || [], + }); + p._aiTurnActive = true; + p._pendingRequests.delete(currentCallId); + p._currentToolCallId = null; + handledAsToolResponse = true; } + } - resolve({ - value: queuedPrompt.prompt, - queue: hasQueuedItems(p), - attachments: queuedPrompt.attachments || [], - }); - p._pendingRequests.delete(currentCallId); - p._currentToolCallId = null; - } else { + if (!handledAsToolResponse) { debugLog( `[TaskSync] handleAddQueuePrompt — no pending tool call, adding to queue (new size: ${p._promptQueue.length + 1})`, ); diff --git a/tasksync-chat/src/webview/toolCallHandler.ts b/tasksync-chat/src/webview/toolCallHandler.ts index e9a39b7..6e4e6e1 100644 --- a/tasksync-chat/src/webview/toolCallHandler.ts +++ b/tasksync-chat/src/webview/toolCallHandler.ts @@ -321,12 +321,12 @@ export async function waitForUserResponse( choices: choices.length > 0 ? choices.map( - (c: { label: string; value: string; shortLabel?: string }) => ({ - label: c.label, - value: c.value, - shortLabel: c.shortLabel, - }), - ) + (c: { label: string; value: string; shortLabel?: string }) => ({ + label: c.label, + value: c.value, + shortLabel: c.shortLabel, + }), + ) : undefined, }); diff --git a/tasksync-chat/src/webview/webviewProvider.ts b/tasksync-chat/src/webview/webviewProvider.ts index 929cb57..d32a1d9 100644 --- a/tasksync-chat/src/webview/webviewProvider.ts +++ b/tasksync-chat/src/webview/webviewProvider.ts @@ -36,11 +36,12 @@ export type { QueuedPrompt, ReusablePrompt, ToolCallEntry, - UserResponseResult + UserResponseResult, } from "./webviewTypes"; export class TaskSyncWebviewProvider - implements vscode.WebviewViewProvider, vscode.Disposable { + implements vscode.WebviewViewProvider, vscode.Disposable +{ public static readonly viewType = VIEW_TYPE; // All underscore-prefixed members are "internal" by convention but public diff --git a/tasksync-chat/src/webview/webviewTypes.ts b/tasksync-chat/src/webview/webviewTypes.ts index ebb5c30..d5a151d 100644 --- a/tasksync-chat/src/webview/webviewTypes.ts +++ b/tasksync-chat/src/webview/webviewTypes.ts @@ -76,18 +76,18 @@ export interface ReusablePrompt { export type ToWebviewMessage = | { type: "updateQueue"; queue: QueuedPrompt[]; enabled: boolean } | { - type: "toolCallPending"; - id: string; - prompt: string; - summary?: string; - isApproval: boolean; - choices?: ParsedChoice[]; - } + type: "toolCallPending"; + id: string; + prompt: string; + summary?: string; + isApproval: boolean; + choices?: ParsedChoice[]; + } | { - type: "toolCallCompleted"; - entry: ToolCallEntry; - sessionTerminated?: boolean; - } + type: "toolCallCompleted"; + entry: ToolCallEntry; + sessionTerminated?: boolean; + } | { type: "updateCurrentSession"; history: ToolCallEntry[] } | { type: "updatePersistedHistory"; history: ToolCallEntry[] } | { type: "fileSearchResults"; files: FileSearchResult[] } @@ -95,43 +95,43 @@ export type ToWebviewMessage = | { type: "imageSaved"; attachment: AttachmentInfo } | { type: "openSettingsModal" } | { - type: "updateSettings"; - soundEnabled: boolean; - interactiveApprovalEnabled: boolean; - autopilotEnabled: boolean; - autopilotText: string; - autopilotPrompts: string[]; - reusablePrompts: ReusablePrompt[]; - responseTimeout: number; - sessionWarningHours: number; - maxConsecutiveAutoResponses: number; - humanLikeDelayEnabled: boolean; - humanLikeDelayMin: number; - humanLikeDelayMax: number; - sendWithCtrlEnter: boolean; - queueEnabled: boolean; - } + type: "updateSettings"; + soundEnabled: boolean; + interactiveApprovalEnabled: boolean; + autopilotEnabled: boolean; + autopilotText: string; + autopilotPrompts: string[]; + reusablePrompts: ReusablePrompt[]; + responseTimeout: number; + sessionWarningHours: number; + maxConsecutiveAutoResponses: number; + humanLikeDelayEnabled: boolean; + humanLikeDelayMin: number; + humanLikeDelayMax: number; + sendWithCtrlEnter: boolean; + queueEnabled: boolean; + } | { type: "slashCommandResults"; prompts: ReusablePrompt[] } | { type: "playNotificationSound" } | { - type: "contextSearchResults"; - suggestions: Array<{ - type: string; - label: string; - description: string; - detail: string; - }>; - } + type: "contextSearchResults"; + suggestions: Array<{ + type: string; + label: string; + description: string; + detail: string; + }>; + } | { - type: "contextReferenceAdded"; - reference: { id: string; type: string; label: string; content: string }; - } + type: "contextReferenceAdded"; + reference: { id: string; type: string; label: string; content: string }; + } | { type: "clear" } | { - type: "updateSessionTimer"; - startTime: number | null; - frozenElapsed: number | null; - } + type: "updateSessionTimer"; + startTime: number | null; + frozenElapsed: number | null; + } | { type: "triggerSendFromShortcut" } | { type: "openHistoryModal" }; @@ -139,11 +139,11 @@ export type ToWebviewMessage = export type FromWebviewMessage = | { type: "submit"; value: string; attachments: AttachmentInfo[] } | { - type: "addQueuePrompt"; - prompt: string; - id: string; - attachments?: AttachmentInfo[]; - } + type: "addQueuePrompt"; + prompt: string; + id: string; + attachments?: AttachmentInfo[]; + } | { type: "removeQueuePrompt"; promptId: string } | { type: "editQueuePrompt"; promptId: string; newPrompt: string } | { type: "reorderQueue"; fromIndex: number; toIndex: number } @@ -183,8 +183,8 @@ export type FromWebviewMessage = | { type: "updateSendWithCtrlEnterSetting"; enabled: boolean } | { type: "searchContext"; query: string } | { - type: "selectContextReference"; - contextType: string; - options?: Record; - } + type: "selectContextReference"; + contextType: string; + options?: Record; + } | { type: "copyToClipboard"; text: string }; From 19043445177d8445afef3512d322ff695271d9fe Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 01:56:53 +0400 Subject: [PATCH 04/35] security: bundle mermaid locally, remove CDN dependency - Install mermaid@10.9.3 as a dependency - Copy mermaid.min.js to media/ during build (esbuild.js) - Load mermaid from local webview URI instead of CDN - Remove cdn.jsdelivr.net from all CSP directives (both webview and remote) - Inject mermaid source path via window.__MERMAID_SRC__ global - Add media/mermaid.min.js to .gitignore (build artifact) --- tasksync-chat/.gitignore | 5 +- tasksync-chat/esbuild.js | 7 + tasksync-chat/media/webview.js | 6 +- tasksync-chat/package-lock.json | 2201 +++++++++++++---- tasksync-chat/package.json | 3 +- tasksync-chat/src/server/remoteHtmlService.ts | 3 +- tasksync-chat/src/webview-ui/rendering.js | 6 +- .../src/webview/lifecycleHandlers.ts | 6 +- 8 files changed, 1769 insertions(+), 468 deletions(-) diff --git a/tasksync-chat/.gitignore b/tasksync-chat/.gitignore index a3530a9..5154b55 100644 --- a/tasksync-chat/.gitignore +++ b/tasksync-chat/.gitignore @@ -40,4 +40,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* -*.vsix \ No newline at end of file +*.vsix + +# Build artifact - copied from node_modules during build +media/mermaid.min.js diff --git a/tasksync-chat/esbuild.js b/tasksync-chat/esbuild.js index 9915426..33e8d86 100644 --- a/tasksync-chat/esbuild.js +++ b/tasksync-chat/esbuild.js @@ -163,6 +163,13 @@ async function main() { buildWebview(); console.log('Webview build complete'); + // Copy mermaid.min.js to media/ for local serving (no CDN dependency) + fs.copyFileSync( + path.join(__dirname, 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'), + path.join(__dirname, 'media', 'mermaid.min.js'), + ); + console.log('Mermaid copied to media/'); + // Build extension (esbuild, TypeScript bundling) const ctx = await esbuild.context({ entryPoints: ['src/extension.ts'], diff --git a/tasksync-chat/media/webview.js b/tasksync-chat/media/webview.js index 2d8275d..de3ddc8 100644 --- a/tasksync-chat/media/webview.js +++ b/tasksync-chat/media/webview.js @@ -3040,11 +3040,7 @@ function loadMermaid(callback) { mermaidLoading = true; let script = document.createElement("script"); - script.src = - "https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js"; - script.crossOrigin = "anonymous"; - script.integrity = - "sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT"; + script.src = window.__MERMAID_SRC__ || "mermaid.min.js"; script.onload = function () { window.mermaid.initialize({ startOnLoad: false, diff --git a/tasksync-chat/package-lock.json b/tasksync-chat/package-lock.json index 1351d65..673e94e 100644 --- a/tasksync-chat/package-lock.json +++ b/tasksync-chat/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.24.3", "@vscode/codicons": "^0.0.36", + "mermaid": "10.9.3", "selfsigned": "^5.5.0", "ws": "^8.18.0", "zod": "^3.23.8" @@ -253,6 +254,12 @@ "node": ">=14.21.3" } }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1336,6 +1343,36 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1350,6 +1387,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", @@ -1360,6 +1412,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.107.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.107.0.tgz", @@ -1671,6 +1729,25 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1724,6 +1801,15 @@ "node": ">= 0.10" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1738,204 +1824,745 @@ "node": ">= 8" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" }, "engines": { - "node": ">=6.0" + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", "dependencies": { - "es-errors": "^1.3.0" + "d3-dispatch": "1 - 3", + "d3-selection": "3" }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, "bin": { - "esbuild": "bin/esbuild" + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "node": ">=12" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", "engines": { - "node": ">= 0.6" + "node": ">=12" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", "dependencies": { - "eventsource-parser": "^3.0.1" + "d3-dsv": "1 - 3" }, "engines": { - "node": ">=18.0.0" + "node": ">=12" } }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, "engines": { - "node": ">=18.0.0" + "node": ">=12" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", "engines": { - "node": ">=12.0.0" + "node": ">=12" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", "dependencies": { - "accepts": "^2.0.0", + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz", + "integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==", + "license": "MIT", + "dependencies": { + "d3": "^7.8.2", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dompurify": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", + "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", @@ -1965,440 +2592,1006 @@ "vary": "^1.1.2" }, "engines": { - "node": ">= 18" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, "engines": { - "node": ">= 16" + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "node_modules/hono": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", + "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">= 0.8" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 18.0.0" + "node": ">=0.10.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 12" } }, - "node_modules/fresh": { + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/katex": { + "version": "0.16.40", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz", + "integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=6" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "semver": "^7.5.3" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { "node": ">= 0.4" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/has-symbols": { + "node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/mermaid": { + "version": "10.9.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.3.tgz", + "integrity": "sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "@braintree/sanitize-url": "^6.0.1", + "@types/d3-scale": "^4.0.3", + "@types/d3-scale-chromatic": "^3.0.0", + "cytoscape": "^3.28.1", + "cytoscape-cose-bilkent": "^4.1.0", + "d3": "^7.4.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.10", + "dayjs": "^1.11.7", + "dompurify": "^3.0.5 <3.1.7", + "elkjs": "^0.9.0", + "katex": "^0.16.9", + "khroma": "^2.0.0", + "lodash-es": "^4.17.21", + "mdast-util-from-markdown": "^1.3.0", + "non-layered-tidy-tree-layout": "^2.0.2", + "stylis": "^4.1.3", + "ts-dedent": "^2.2.0", + "uuid": "^9.0.0", + "web-worker": "^1.2.0" + } + }, + "node_modules/micromark": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-factory-destination": "^1.0.0", + "micromark-factory-label": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-factory-title": "^1.0.0", + "micromark-factory-whitespace": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-html-tag-name": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "node_modules/micromark-factory-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/micromark-factory-title": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/micromark-factory-whitespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 12" + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/micromark-util-chunked": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.10" + "dependencies": { + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" + "node_modules/micromark-util-classify-character": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/micromark-util-combine-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "micromark-util-chunked": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "node_modules/micromark-util-decode-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, + "node_modules/micromark-util-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/micromark-util-html-tag-name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/micromark-util-normalize-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, + "node_modules/micromark-util-resolve-all": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "micromark-util-types": "^1.0.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, + "node_modules/micromark-util-sanitize-uri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "micromark-util-character": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/math-intrinsics": { + "node_modules/micromark-util-subtokenize": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" } }, - "node_modules/media-typer": { + "node_modules/micromark-util-symbol": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, "node_modules/mime-db": { "version": "1.54.0", @@ -2425,6 +3618,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2459,6 +3661,12 @@ "node": ">= 0.6" } }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2560,7 +3768,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2740,6 +3947,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2801,6 +4014,24 @@ "node": ">= 18" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3017,6 +4248,12 @@ "dev": true, "license": "MIT" }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3083,6 +4320,15 @@ "node": ">=0.6" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3142,6 +4388,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3151,6 +4410,37 @@ "node": ">= 0.8" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uvu": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0", + "diff": "^5.0.0", + "kleur": "^4.0.3", + "sade": "^1.7.3" + }, + "bin": { + "uvu": "bin.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3166,7 +4456,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3257,7 +4546,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -3330,6 +4618,12 @@ } } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3394,7 +4688,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index abed4ea..95a9078 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -382,6 +382,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.24.3", "@vscode/codicons": "^0.0.36", + "mermaid": "10.9.3", "selfsigned": "^5.5.0", "ws": "^8.18.0", "zod": "^3.23.8" @@ -397,4 +398,4 @@ "typescript": "^5.3.3", "vitest": "^4.0.18" } -} \ No newline at end of file +} diff --git a/tasksync-chat/src/server/remoteHtmlService.ts b/tasksync-chat/src/server/remoteHtmlService.ts index 920da0b..f4e221f 100644 --- a/tasksync-chat/src/server/remoteHtmlService.ts +++ b/tasksync-chat/src/server/remoteHtmlService.ts @@ -336,7 +336,7 @@ export class RemoteHtmlService { - + TaskSync Remote @@ -370,6 +370,7 @@ export class RemoteHtmlService { ${bodyHtml} + diff --git a/tasksync-chat/src/webview-ui/rendering.js b/tasksync-chat/src/webview-ui/rendering.js index 756f1da..2c87e12 100644 --- a/tasksync-chat/src/webview-ui/rendering.js +++ b/tasksync-chat/src/webview-ui/rendering.js @@ -462,11 +462,7 @@ function loadMermaid(callback) { mermaidLoading = true; let script = document.createElement("script"); - script.src = - "https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js"; - script.crossOrigin = "anonymous"; - script.integrity = - "sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT"; + script.src = window.__MERMAID_SRC__ || "mermaid.min.js"; script.onload = function () { window.mermaid.initialize({ startOnLoad: false, diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index 932d497..923ac8d 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -44,6 +44,9 @@ export function getHtmlContent( const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(extensionUri, "media", "webview.js"), ); + const mermaidScriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "mermaid.min.js"), + ); const codiconsUri = webview.asWebviewUri( vscode.Uri.joinPath( extensionUri, @@ -83,7 +86,7 @@ export function getHtmlContent( - + TaskSync Chat @@ -91,6 +94,7 @@ export function getHtmlContent( ${bodyHtml} + From 1a3e4e20cbfa9c563beea7b3c1146506c751fb25 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 01:58:16 +0400 Subject: [PATCH 05/35] style: format code for consistency and readability across multiple files --- .../src/server/remoteAuthService.test.ts | 676 +++++++++--------- tasksync-chat/src/server/remoteAuthService.ts | 2 +- tasksync-chat/src/server/remoteHtmlService.ts | 2 +- tasksync-chat/src/webview/persistence.ts | 4 +- .../queueHandlers.comprehensive.test.ts | 2 +- 5 files changed, 343 insertions(+), 343 deletions(-) diff --git a/tasksync-chat/src/server/remoteAuthService.test.ts b/tasksync-chat/src/server/remoteAuthService.test.ts index c71603f..4d2df5c 100644 --- a/tasksync-chat/src/server/remoteAuthService.test.ts +++ b/tasksync-chat/src/server/remoteAuthService.test.ts @@ -4,33 +4,33 @@ import { RemoteAuthService } from "./remoteAuthService"; // ─── Helpers ───────────────────────────────────────────────── function createMockContext() { - const store = new Map(); - return { - globalState: { - get: (key: string) => store.get(key), - update: (key: string, value: unknown) => { - store.set(key, value); - return Promise.resolve(); - }, - }, - } as any; + const store = new Map(); + return { + globalState: { + get: (key: string) => store.get(key), + update: (key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }, + }, + } as any; } function createMockWs() { - const sent: string[] = []; - return { - send: (data: string) => sent.push(data), - _sent: sent, - _parsed: () => sent.map((s) => JSON.parse(s)), - }; + const sent: string[] = []; + return { + send: (data: string) => sent.push(data), + _sent: sent, + _parsed: () => sent.map((s) => JSON.parse(s)), + }; } function createService(pin = "123456") { - const ctx = createMockContext(); - const svc = new RemoteAuthService(ctx); - svc.pin = pin; - svc.pinEnabled = true; - return { svc, ctx }; + const ctx = createMockContext(); + const svc = new RemoteAuthService(ctx); + svc.pin = pin; + svc.pinEnabled = true; + return { svc, ctx }; } const DUMMY_STATE = { pending: null, queue: [] }; @@ -39,351 +39,351 @@ const GET_STATE = () => DUMMY_STATE; // ─── normalizeIp ───────────────────────────────────────────── describe("normalizeIp", () => { - it("strips IPv6-mapped IPv4 prefix", () => { - const { svc } = createService(); - expect(svc.normalizeIp("::ffff:127.0.0.1")).toBe("127.0.0.1"); - }); - - it("passes through plain IPv4", () => { - const { svc } = createService(); - expect(svc.normalizeIp("192.168.1.1")).toBe("192.168.1.1"); - }); - - it("passes through IPv6", () => { - const { svc } = createService(); - expect(svc.normalizeIp("::1")).toBe("::1"); - }); + it("strips IPv6-mapped IPv4 prefix", () => { + const { svc } = createService(); + expect(svc.normalizeIp("::ffff:127.0.0.1")).toBe("127.0.0.1"); + }); + + it("passes through plain IPv4", () => { + const { svc } = createService(); + expect(svc.normalizeIp("192.168.1.1")).toBe("192.168.1.1"); + }); + + it("passes through IPv6", () => { + const { svc } = createService(); + expect(svc.normalizeIp("::1")).toBe("::1"); + }); }); // ─── handleAuth — no-PIN mode ──────────────────────────────── describe("handleAuth — no-PIN mode", () => { - it("authenticates and sends authSuccess when client not yet authenticated", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const ws = createMockWs(); - - svc.handleAuth(ws as any, "1.2.3.4", undefined, undefined, GET_STATE, true); - - expect(svc.authenticatedClients.has(ws as any)).toBe(true); - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authSuccess"); - expect(msgs[0].gitServiceAvailable).toBe(true); - }); - - it("does not send duplicate authSuccess for already-authenticated client", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const ws = createMockWs(); - svc.authenticatedClients.add(ws as any); - - svc.handleAuth( - ws as any, - "1.2.3.4", - undefined, - undefined, - GET_STATE, - false, - ); - - expect(ws._sent).toHaveLength(0); - }); + it("authenticates and sends authSuccess when client not yet authenticated", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + + svc.handleAuth(ws as any, "1.2.3.4", undefined, undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(true); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + expect(msgs[0].gitServiceAvailable).toBe(true); + }); + + it("does not send duplicate authSuccess for already-authenticated client", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.handleAuth( + ws as any, + "1.2.3.4", + undefined, + undefined, + GET_STATE, + false, + ); + + expect(ws._sent).toHaveLength(0); + }); }); // ─── handleAuth — PIN auth ─────────────────────────────────── describe("handleAuth — PIN auth", () => { - it("authenticates with correct PIN and returns session token", () => { - const { svc } = createService("654321"); - const ws = createMockWs(); - - svc.handleAuth(ws as any, "10.0.0.1", "654321", undefined, GET_STATE, true); - - expect(svc.authenticatedClients.has(ws as any)).toBe(true); - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authSuccess"); - expect(msgs[0].sessionToken).toBeDefined(); - expect(msgs[0].sessionToken).toMatch(/^[a-f0-9]{64}$/); - }); - - it("rejects wrong PIN and tracks failed attempt", () => { - const { svc } = createService("123456"); - const ws = createMockWs(); - - svc.handleAuth(ws as any, "10.0.0.1", "000000", undefined, GET_STATE, true); - - expect(svc.authenticatedClients.has(ws as any)).toBe(false); - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authFailed"); - expect(msgs[0].message).toContain("Wrong PIN"); - expect(msgs[0].message).toContain("4 attempts left"); - }); - - it("locks out after 5 consecutive failed attempts", () => { - const { svc } = createService("123456"); - const ip = "10.0.0.99"; - - for (let i = 0; i < 5; i++) { - const ws = createMockWs(); - svc.handleAuth(ws as any, ip, "wrong!", undefined, GET_STATE, true); - } - - // 6th attempt should show lockout - const ws = createMockWs(); - svc.handleAuth(ws as any, ip, "123456", undefined, GET_STATE, true); - const msgs = ws._parsed(); - expect(msgs[0].type).toBe("authFailed"); - expect(msgs[0].message).toContain("Locked"); - }); - - it("calls onAuthFailure callback on failed attempts", () => { - const { svc } = createService("123456"); - const failureCb = vi.fn(); - svc.onAuthFailure = failureCb; - const ws = createMockWs(); - - svc.handleAuth(ws as any, "10.0.0.5", "wrong", undefined, GET_STATE, true); - - expect(failureCb).toHaveBeenCalledWith("10.0.0.5", 1, false); - }); - - it("sends error when getState throws", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const ws = createMockWs(); - const badGetState = () => { - throw new Error("state failure"); - }; - - svc.handleAuth( - ws as any, - "10.0.0.1", - undefined, - undefined, - badGetState, - true, - ); - - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("error"); - }); + it("authenticates with correct PIN and returns session token", () => { + const { svc } = createService("654321"); + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.1", "654321", undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(true); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + expect(msgs[0].sessionToken).toBeDefined(); + expect(msgs[0].sessionToken).toMatch(/^[a-f0-9]{64}$/); + }); + + it("rejects wrong PIN and tracks failed attempt", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.1", "000000", undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(false); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Wrong PIN"); + expect(msgs[0].message).toContain("4 attempts left"); + }); + + it("locks out after 5 consecutive failed attempts", () => { + const { svc } = createService("123456"); + const ip = "10.0.0.99"; + + for (let i = 0; i < 5; i++) { + const ws = createMockWs(); + svc.handleAuth(ws as any, ip, "wrong!", undefined, GET_STATE, true); + } + + // 6th attempt should show lockout + const ws = createMockWs(); + svc.handleAuth(ws as any, ip, "123456", undefined, GET_STATE, true); + const msgs = ws._parsed(); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Locked"); + }); + + it("calls onAuthFailure callback on failed attempts", () => { + const { svc } = createService("123456"); + const failureCb = vi.fn(); + svc.onAuthFailure = failureCb; + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.5", "wrong", undefined, GET_STATE, true); + + expect(failureCb).toHaveBeenCalledWith("10.0.0.5", 1, false); + }); + + it("sends error when getState throws", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + const badGetState = () => { + throw new Error("state failure"); + }; + + svc.handleAuth( + ws as any, + "10.0.0.1", + undefined, + undefined, + badGetState, + true, + ); + + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("error"); + }); }); // ─── handleAuth — session token auth ───────────────────────── describe("handleAuth — session token auth", () => { - it("authenticates with valid session token and rotates it", () => { - const { svc } = createService("123456"); - const ip = "10.0.0.1"; - - // First: authenticate with PIN to get a session token - const ws1 = createMockWs(); - svc.handleAuth(ws1 as any, ip, "123456", undefined, GET_STATE, true); - const token1 = ws1._parsed()[0].sessionToken; - expect(token1).toMatch(/^[a-f0-9]{64}$/); - - // Clean up first ws from authenticated set - svc.removeClient(ws1 as any); - - // Second: reconnect with session token - const ws2 = createMockWs(); - svc.handleAuth(ws2 as any, ip, undefined, token1, GET_STATE, true); - const msgs = ws2._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authSuccess"); - // Token should be rotated - expect(msgs[0].sessionToken).toBeDefined(); - expect(msgs[0].sessionToken).not.toBe(token1); - }); - - it("rejects session token from different IP", () => { - const { svc } = createService("123456"); - - // Authenticate from IP A - const ws1 = createMockWs(); - svc.handleAuth( - ws1 as any, - "10.0.0.1", - "123456", - undefined, - GET_STATE, - true, - ); - const token = ws1._parsed()[0].sessionToken; - - // Try to use token from IP B — should fall through to PIN auth and fail - const ws2 = createMockWs(); - svc.handleAuth(ws2 as any, "10.0.0.2", undefined, token, GET_STATE, true); - const msgs = ws2._parsed(); - expect(msgs[0].type).toBe("authFailed"); - }); - - it("rejects malformed session token and falls through to PIN auth", () => { - const { svc } = createService("123456"); - const ws = createMockWs(); - - svc.handleAuth( - ws as any, - "10.0.0.1", - undefined, - "not-valid-hex", - GET_STATE, - true, - ); - - const msgs = ws._parsed(); - expect(msgs[0].type).toBe("authFailed"); - expect(msgs[0].message).toContain("Wrong PIN"); - }); + it("authenticates with valid session token and rotates it", () => { + const { svc } = createService("123456"); + const ip = "10.0.0.1"; + + // First: authenticate with PIN to get a session token + const ws1 = createMockWs(); + svc.handleAuth(ws1 as any, ip, "123456", undefined, GET_STATE, true); + const token1 = ws1._parsed()[0].sessionToken; + expect(token1).toMatch(/^[a-f0-9]{64}$/); + + // Clean up first ws from authenticated set + svc.removeClient(ws1 as any); + + // Second: reconnect with session token + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, ip, undefined, token1, GET_STATE, true); + const msgs = ws2._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + // Token should be rotated + expect(msgs[0].sessionToken).toBeDefined(); + expect(msgs[0].sessionToken).not.toBe(token1); + }); + + it("rejects session token from different IP", () => { + const { svc } = createService("123456"); + + // Authenticate from IP A + const ws1 = createMockWs(); + svc.handleAuth( + ws1 as any, + "10.0.0.1", + "123456", + undefined, + GET_STATE, + true, + ); + const token = ws1._parsed()[0].sessionToken; + + // Try to use token from IP B — should fall through to PIN auth and fail + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, "10.0.0.2", undefined, token, GET_STATE, true); + const msgs = ws2._parsed(); + expect(msgs[0].type).toBe("authFailed"); + }); + + it("rejects malformed session token and falls through to PIN auth", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + + svc.handleAuth( + ws as any, + "10.0.0.1", + undefined, + "not-valid-hex", + GET_STATE, + true, + ); + + const msgs = ws._parsed(); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Wrong PIN"); + }); }); // ─── verifyHttpAuth ────────────────────────────────────────── describe("verifyHttpAuth", () => { - function createMockReq( - headerPin?: string, - ip = "10.0.0.1", - ): import("http").IncomingMessage { - return { - headers: headerPin ? { "x-tasksync-pin": headerPin } : {}, - socket: { remoteAddress: ip }, - } as any; - } - - it("allows all requests when PIN is disabled", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const req = createMockReq(); - const url = new URL("http://localhost/api/test"); - - expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); - }); - - it("allows requests with correct PIN header", () => { - const { svc } = createService("654321"); - const req = createMockReq("654321"); - const url = new URL("http://localhost/api/test"); - - expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); - }); - - it("rejects requests with wrong PIN header", () => { - const { svc } = createService("654321"); - const req = createMockReq("000000"); - const url = new URL("http://localhost/api/test"); - - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); - }); - - it("rejects requests with no PIN", () => { - const { svc } = createService("654321"); - const req = createMockReq(); - const url = new URL("http://localhost/api/test"); - - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); - }); - - it("accepts PIN via query string", () => { - const { svc } = createService("654321"); - const req = createMockReq(); - const url = new URL("http://localhost/api/test?pin=654321"); - - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(true); - }); - - it("locks out after repeated failures", () => { - const { svc } = createService("654321"); - const url = new URL("http://localhost/api/test"); - - for (let i = 0; i < 5; i++) { - const req = createMockReq("wrong", "10.0.0.50"); - svc.verifyHttpAuth(req, url); - } - - const req = createMockReq("654321", "10.0.0.50"); - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); - expect(result.lockedOut).toBe(true); - }); + function createMockReq( + headerPin?: string, + ip = "10.0.0.1", + ): import("http").IncomingMessage { + return { + headers: headerPin ? { "x-tasksync-pin": headerPin } : {}, + socket: { remoteAddress: ip }, + } as any; + } + + it("allows all requests when PIN is disabled", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const req = createMockReq(); + const url = new URL("http://localhost/api/test"); + + expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); + }); + + it("allows requests with correct PIN header", () => { + const { svc } = createService("654321"); + const req = createMockReq("654321"); + const url = new URL("http://localhost/api/test"); + + expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); + }); + + it("rejects requests with wrong PIN header", () => { + const { svc } = createService("654321"); + const req = createMockReq("000000"); + const url = new URL("http://localhost/api/test"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("rejects requests with no PIN", () => { + const { svc } = createService("654321"); + const req = createMockReq(); + const url = new URL("http://localhost/api/test"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("accepts PIN via query string", () => { + const { svc } = createService("654321"); + const req = createMockReq(); + const url = new URL("http://localhost/api/test?pin=654321"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(true); + }); + + it("locks out after repeated failures", () => { + const { svc } = createService("654321"); + const url = new URL("http://localhost/api/test"); + + for (let i = 0; i < 5; i++) { + const req = createMockReq("wrong", "10.0.0.50"); + svc.verifyHttpAuth(req, url); + } + + const req = createMockReq("654321", "10.0.0.50"); + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + expect(result.lockedOut).toBe(true); + }); }); // ─── getOrCreatePin ────────────────────────────────────────── describe("getOrCreatePin", () => { - it("generates a 6-digit PIN when none exists", () => { - const ctx = createMockContext(); - const svc = new RemoteAuthService(ctx); - - const pin = svc.getOrCreatePin(); - expect(pin).toMatch(/^\d{6}$/); - }); - - it("returns persisted PIN from globalState", () => { - const ctx = createMockContext(); - ctx.globalState.update("remotePin", "987654"); - const svc = new RemoteAuthService(ctx); - - expect(svc.getOrCreatePin()).toBe("987654"); - }); - - it("upgrades short PINs to 6 digits", () => { - const ctx = createMockContext(); - ctx.globalState.update("remotePin", "1234"); - const svc = new RemoteAuthService(ctx); - - const pin = svc.getOrCreatePin(); - expect(pin).toMatch(/^\d{6}$/); - expect(pin).not.toBe("1234"); - }); + it("generates a 6-digit PIN when none exists", () => { + const ctx = createMockContext(); + const svc = new RemoteAuthService(ctx); + + const pin = svc.getOrCreatePin(); + expect(pin).toMatch(/^\d{6}$/); + }); + + it("returns persisted PIN from globalState", () => { + const ctx = createMockContext(); + ctx.globalState.update("remotePin", "987654"); + const svc = new RemoteAuthService(ctx); + + expect(svc.getOrCreatePin()).toBe("987654"); + }); + + it("upgrades short PINs to 6 digits", () => { + const ctx = createMockContext(); + ctx.globalState.update("remotePin", "1234"); + const svc = new RemoteAuthService(ctx); + + const pin = svc.getOrCreatePin(); + expect(pin).toMatch(/^\d{6}$/); + expect(pin).not.toBe("1234"); + }); }); // ─── cleanup / removeClient / clearSessionTokens ──────────── describe("lifecycle methods", () => { - it("removeClient removes from authenticated set", () => { - const { svc } = createService(); - const ws = createMockWs(); - svc.authenticatedClients.add(ws as any); - - svc.removeClient(ws as any); - expect(svc.authenticatedClients.has(ws as any)).toBe(false); - }); - - it("clearSessionTokens clears all tokens", () => { - const { svc } = createService("123456"); - const ws = createMockWs(); - svc.handleAuth(ws as any, "10.0.0.1", "123456", undefined, GET_STATE, true); - - svc.clearSessionTokens(); - - // Token from earlier auth should no longer work - const token = ws._parsed()[0].sessionToken; - const ws2 = createMockWs(); - svc.handleAuth(ws2 as any, "10.0.0.1", undefined, token, GET_STATE, true); - expect(ws2._parsed()[0].type).toBe("authFailed"); - }); - - it("cleanup clears all state", () => { - const { svc } = createService(); - const ws = createMockWs(); - svc.authenticatedClients.add(ws as any); - - svc.cleanup(); - - expect(svc.authenticatedClients.size).toBe(0); - }); - - it("startFailedAttemptsCleanup does not throw when called twice", () => { - const { svc } = createService(); - svc.startFailedAttemptsCleanup(); - svc.startFailedAttemptsCleanup(); - svc.cleanup(); // clean up timer - }); + it("removeClient removes from authenticated set", () => { + const { svc } = createService(); + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.removeClient(ws as any); + expect(svc.authenticatedClients.has(ws as any)).toBe(false); + }); + + it("clearSessionTokens clears all tokens", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + svc.handleAuth(ws as any, "10.0.0.1", "123456", undefined, GET_STATE, true); + + svc.clearSessionTokens(); + + // Token from earlier auth should no longer work + const token = ws._parsed()[0].sessionToken; + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, "10.0.0.1", undefined, token, GET_STATE, true); + expect(ws2._parsed()[0].type).toBe("authFailed"); + }); + + it("cleanup clears all state", () => { + const { svc } = createService(); + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.cleanup(); + + expect(svc.authenticatedClients.size).toBe(0); + }); + + it("startFailedAttemptsCleanup does not throw when called twice", () => { + const { svc } = createService(); + svc.startFailedAttemptsCleanup(); + svc.startFailedAttemptsCleanup(); + svc.cleanup(); // clean up timer + }); }); diff --git a/tasksync-chat/src/server/remoteAuthService.ts b/tasksync-chat/src/server/remoteAuthService.ts index d7aa0fd..ff64382 100644 --- a/tasksync-chat/src/server/remoteAuthService.ts +++ b/tasksync-chat/src/server/remoteAuthService.ts @@ -68,7 +68,7 @@ export class RemoteAuthService { return attempt; } - constructor(private context: vscode.ExtensionContext) {} + constructor(private context: vscode.ExtensionContext) { } /** * Handle PIN/session-token authentication for a WebSocket client. diff --git a/tasksync-chat/src/server/remoteHtmlService.ts b/tasksync-chat/src/server/remoteHtmlService.ts index f4e221f..f8b2e62 100644 --- a/tasksync-chat/src/server/remoteHtmlService.ts +++ b/tasksync-chat/src/server/remoteHtmlService.ts @@ -43,7 +43,7 @@ export class RemoteHtmlService { constructor( private webDir: string, private mediaDir: string, - ) {} + ) { } /** * Preload HTML templates asynchronously during server startup. diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index c9cf88b..dd53b4c 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -96,8 +96,8 @@ export async function loadPersistedHistoryFromDiskAsync(p: P): Promise { const parsed = JSON.parse(data); p._persistedHistory = Array.isArray(parsed.history) ? parsed.history - .filter((entry: ToolCallEntry) => entry.status === "completed") - .slice(0, p._MAX_HISTORY_ENTRIES) + .filter((entry: ToolCallEntry) => entry.status === "completed") + .slice(0, p._MAX_HISTORY_ENTRIES) : []; } catch (error) { console.error("[TaskSync] Failed to load persisted history:", error); diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index 4f00a21..9090a43 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -164,7 +164,7 @@ describe("handleAddQueuePrompt", () => { const resolve = vi.fn(); const pendingRequests = new Map(); pendingRequests.set("tc_1", resolve); - const timer = setTimeout(() => {}, 10000); + const timer = setTimeout(() => { }, 10000); const p = createMockP({ _queueEnabled: true, From b3b47fd85874305805319d3260a24fcfcfc35a8d Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 02:02:20 +0400 Subject: [PATCH 06/35] style: fix biome formatting issues --- .../src/server/remoteAuthService.test.ts | 676 +++++++++--------- tasksync-chat/src/server/remoteAuthService.ts | 2 +- tasksync-chat/src/server/remoteHtmlService.ts | 2 +- tasksync-chat/src/webview/persistence.ts | 4 +- .../queueHandlers.comprehensive.test.ts | 2 +- 5 files changed, 343 insertions(+), 343 deletions(-) diff --git a/tasksync-chat/src/server/remoteAuthService.test.ts b/tasksync-chat/src/server/remoteAuthService.test.ts index 4d2df5c..c71603f 100644 --- a/tasksync-chat/src/server/remoteAuthService.test.ts +++ b/tasksync-chat/src/server/remoteAuthService.test.ts @@ -4,33 +4,33 @@ import { RemoteAuthService } from "./remoteAuthService"; // ─── Helpers ───────────────────────────────────────────────── function createMockContext() { - const store = new Map(); - return { - globalState: { - get: (key: string) => store.get(key), - update: (key: string, value: unknown) => { - store.set(key, value); - return Promise.resolve(); - }, - }, - } as any; + const store = new Map(); + return { + globalState: { + get: (key: string) => store.get(key), + update: (key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }, + }, + } as any; } function createMockWs() { - const sent: string[] = []; - return { - send: (data: string) => sent.push(data), - _sent: sent, - _parsed: () => sent.map((s) => JSON.parse(s)), - }; + const sent: string[] = []; + return { + send: (data: string) => sent.push(data), + _sent: sent, + _parsed: () => sent.map((s) => JSON.parse(s)), + }; } function createService(pin = "123456") { - const ctx = createMockContext(); - const svc = new RemoteAuthService(ctx); - svc.pin = pin; - svc.pinEnabled = true; - return { svc, ctx }; + const ctx = createMockContext(); + const svc = new RemoteAuthService(ctx); + svc.pin = pin; + svc.pinEnabled = true; + return { svc, ctx }; } const DUMMY_STATE = { pending: null, queue: [] }; @@ -39,351 +39,351 @@ const GET_STATE = () => DUMMY_STATE; // ─── normalizeIp ───────────────────────────────────────────── describe("normalizeIp", () => { - it("strips IPv6-mapped IPv4 prefix", () => { - const { svc } = createService(); - expect(svc.normalizeIp("::ffff:127.0.0.1")).toBe("127.0.0.1"); - }); - - it("passes through plain IPv4", () => { - const { svc } = createService(); - expect(svc.normalizeIp("192.168.1.1")).toBe("192.168.1.1"); - }); - - it("passes through IPv6", () => { - const { svc } = createService(); - expect(svc.normalizeIp("::1")).toBe("::1"); - }); + it("strips IPv6-mapped IPv4 prefix", () => { + const { svc } = createService(); + expect(svc.normalizeIp("::ffff:127.0.0.1")).toBe("127.0.0.1"); + }); + + it("passes through plain IPv4", () => { + const { svc } = createService(); + expect(svc.normalizeIp("192.168.1.1")).toBe("192.168.1.1"); + }); + + it("passes through IPv6", () => { + const { svc } = createService(); + expect(svc.normalizeIp("::1")).toBe("::1"); + }); }); // ─── handleAuth — no-PIN mode ──────────────────────────────── describe("handleAuth — no-PIN mode", () => { - it("authenticates and sends authSuccess when client not yet authenticated", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const ws = createMockWs(); - - svc.handleAuth(ws as any, "1.2.3.4", undefined, undefined, GET_STATE, true); - - expect(svc.authenticatedClients.has(ws as any)).toBe(true); - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authSuccess"); - expect(msgs[0].gitServiceAvailable).toBe(true); - }); - - it("does not send duplicate authSuccess for already-authenticated client", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const ws = createMockWs(); - svc.authenticatedClients.add(ws as any); - - svc.handleAuth( - ws as any, - "1.2.3.4", - undefined, - undefined, - GET_STATE, - false, - ); - - expect(ws._sent).toHaveLength(0); - }); + it("authenticates and sends authSuccess when client not yet authenticated", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + + svc.handleAuth(ws as any, "1.2.3.4", undefined, undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(true); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + expect(msgs[0].gitServiceAvailable).toBe(true); + }); + + it("does not send duplicate authSuccess for already-authenticated client", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.handleAuth( + ws as any, + "1.2.3.4", + undefined, + undefined, + GET_STATE, + false, + ); + + expect(ws._sent).toHaveLength(0); + }); }); // ─── handleAuth — PIN auth ─────────────────────────────────── describe("handleAuth — PIN auth", () => { - it("authenticates with correct PIN and returns session token", () => { - const { svc } = createService("654321"); - const ws = createMockWs(); - - svc.handleAuth(ws as any, "10.0.0.1", "654321", undefined, GET_STATE, true); - - expect(svc.authenticatedClients.has(ws as any)).toBe(true); - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authSuccess"); - expect(msgs[0].sessionToken).toBeDefined(); - expect(msgs[0].sessionToken).toMatch(/^[a-f0-9]{64}$/); - }); - - it("rejects wrong PIN and tracks failed attempt", () => { - const { svc } = createService("123456"); - const ws = createMockWs(); - - svc.handleAuth(ws as any, "10.0.0.1", "000000", undefined, GET_STATE, true); - - expect(svc.authenticatedClients.has(ws as any)).toBe(false); - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authFailed"); - expect(msgs[0].message).toContain("Wrong PIN"); - expect(msgs[0].message).toContain("4 attempts left"); - }); - - it("locks out after 5 consecutive failed attempts", () => { - const { svc } = createService("123456"); - const ip = "10.0.0.99"; - - for (let i = 0; i < 5; i++) { - const ws = createMockWs(); - svc.handleAuth(ws as any, ip, "wrong!", undefined, GET_STATE, true); - } - - // 6th attempt should show lockout - const ws = createMockWs(); - svc.handleAuth(ws as any, ip, "123456", undefined, GET_STATE, true); - const msgs = ws._parsed(); - expect(msgs[0].type).toBe("authFailed"); - expect(msgs[0].message).toContain("Locked"); - }); - - it("calls onAuthFailure callback on failed attempts", () => { - const { svc } = createService("123456"); - const failureCb = vi.fn(); - svc.onAuthFailure = failureCb; - const ws = createMockWs(); - - svc.handleAuth(ws as any, "10.0.0.5", "wrong", undefined, GET_STATE, true); - - expect(failureCb).toHaveBeenCalledWith("10.0.0.5", 1, false); - }); - - it("sends error when getState throws", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const ws = createMockWs(); - const badGetState = () => { - throw new Error("state failure"); - }; - - svc.handleAuth( - ws as any, - "10.0.0.1", - undefined, - undefined, - badGetState, - true, - ); - - const msgs = ws._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("error"); - }); + it("authenticates with correct PIN and returns session token", () => { + const { svc } = createService("654321"); + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.1", "654321", undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(true); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + expect(msgs[0].sessionToken).toBeDefined(); + expect(msgs[0].sessionToken).toMatch(/^[a-f0-9]{64}$/); + }); + + it("rejects wrong PIN and tracks failed attempt", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.1", "000000", undefined, GET_STATE, true); + + expect(svc.authenticatedClients.has(ws as any)).toBe(false); + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Wrong PIN"); + expect(msgs[0].message).toContain("4 attempts left"); + }); + + it("locks out after 5 consecutive failed attempts", () => { + const { svc } = createService("123456"); + const ip = "10.0.0.99"; + + for (let i = 0; i < 5; i++) { + const ws = createMockWs(); + svc.handleAuth(ws as any, ip, "wrong!", undefined, GET_STATE, true); + } + + // 6th attempt should show lockout + const ws = createMockWs(); + svc.handleAuth(ws as any, ip, "123456", undefined, GET_STATE, true); + const msgs = ws._parsed(); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Locked"); + }); + + it("calls onAuthFailure callback on failed attempts", () => { + const { svc } = createService("123456"); + const failureCb = vi.fn(); + svc.onAuthFailure = failureCb; + const ws = createMockWs(); + + svc.handleAuth(ws as any, "10.0.0.5", "wrong", undefined, GET_STATE, true); + + expect(failureCb).toHaveBeenCalledWith("10.0.0.5", 1, false); + }); + + it("sends error when getState throws", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const ws = createMockWs(); + const badGetState = () => { + throw new Error("state failure"); + }; + + svc.handleAuth( + ws as any, + "10.0.0.1", + undefined, + undefined, + badGetState, + true, + ); + + const msgs = ws._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("error"); + }); }); // ─── handleAuth — session token auth ───────────────────────── describe("handleAuth — session token auth", () => { - it("authenticates with valid session token and rotates it", () => { - const { svc } = createService("123456"); - const ip = "10.0.0.1"; - - // First: authenticate with PIN to get a session token - const ws1 = createMockWs(); - svc.handleAuth(ws1 as any, ip, "123456", undefined, GET_STATE, true); - const token1 = ws1._parsed()[0].sessionToken; - expect(token1).toMatch(/^[a-f0-9]{64}$/); - - // Clean up first ws from authenticated set - svc.removeClient(ws1 as any); - - // Second: reconnect with session token - const ws2 = createMockWs(); - svc.handleAuth(ws2 as any, ip, undefined, token1, GET_STATE, true); - const msgs = ws2._parsed(); - expect(msgs).toHaveLength(1); - expect(msgs[0].type).toBe("authSuccess"); - // Token should be rotated - expect(msgs[0].sessionToken).toBeDefined(); - expect(msgs[0].sessionToken).not.toBe(token1); - }); - - it("rejects session token from different IP", () => { - const { svc } = createService("123456"); - - // Authenticate from IP A - const ws1 = createMockWs(); - svc.handleAuth( - ws1 as any, - "10.0.0.1", - "123456", - undefined, - GET_STATE, - true, - ); - const token = ws1._parsed()[0].sessionToken; - - // Try to use token from IP B — should fall through to PIN auth and fail - const ws2 = createMockWs(); - svc.handleAuth(ws2 as any, "10.0.0.2", undefined, token, GET_STATE, true); - const msgs = ws2._parsed(); - expect(msgs[0].type).toBe("authFailed"); - }); - - it("rejects malformed session token and falls through to PIN auth", () => { - const { svc } = createService("123456"); - const ws = createMockWs(); - - svc.handleAuth( - ws as any, - "10.0.0.1", - undefined, - "not-valid-hex", - GET_STATE, - true, - ); - - const msgs = ws._parsed(); - expect(msgs[0].type).toBe("authFailed"); - expect(msgs[0].message).toContain("Wrong PIN"); - }); + it("authenticates with valid session token and rotates it", () => { + const { svc } = createService("123456"); + const ip = "10.0.0.1"; + + // First: authenticate with PIN to get a session token + const ws1 = createMockWs(); + svc.handleAuth(ws1 as any, ip, "123456", undefined, GET_STATE, true); + const token1 = ws1._parsed()[0].sessionToken; + expect(token1).toMatch(/^[a-f0-9]{64}$/); + + // Clean up first ws from authenticated set + svc.removeClient(ws1 as any); + + // Second: reconnect with session token + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, ip, undefined, token1, GET_STATE, true); + const msgs = ws2._parsed(); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("authSuccess"); + // Token should be rotated + expect(msgs[0].sessionToken).toBeDefined(); + expect(msgs[0].sessionToken).not.toBe(token1); + }); + + it("rejects session token from different IP", () => { + const { svc } = createService("123456"); + + // Authenticate from IP A + const ws1 = createMockWs(); + svc.handleAuth( + ws1 as any, + "10.0.0.1", + "123456", + undefined, + GET_STATE, + true, + ); + const token = ws1._parsed()[0].sessionToken; + + // Try to use token from IP B — should fall through to PIN auth and fail + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, "10.0.0.2", undefined, token, GET_STATE, true); + const msgs = ws2._parsed(); + expect(msgs[0].type).toBe("authFailed"); + }); + + it("rejects malformed session token and falls through to PIN auth", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + + svc.handleAuth( + ws as any, + "10.0.0.1", + undefined, + "not-valid-hex", + GET_STATE, + true, + ); + + const msgs = ws._parsed(); + expect(msgs[0].type).toBe("authFailed"); + expect(msgs[0].message).toContain("Wrong PIN"); + }); }); // ─── verifyHttpAuth ────────────────────────────────────────── describe("verifyHttpAuth", () => { - function createMockReq( - headerPin?: string, - ip = "10.0.0.1", - ): import("http").IncomingMessage { - return { - headers: headerPin ? { "x-tasksync-pin": headerPin } : {}, - socket: { remoteAddress: ip }, - } as any; - } - - it("allows all requests when PIN is disabled", () => { - const { svc } = createService(); - svc.pinEnabled = false; - const req = createMockReq(); - const url = new URL("http://localhost/api/test"); - - expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); - }); - - it("allows requests with correct PIN header", () => { - const { svc } = createService("654321"); - const req = createMockReq("654321"); - const url = new URL("http://localhost/api/test"); - - expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); - }); - - it("rejects requests with wrong PIN header", () => { - const { svc } = createService("654321"); - const req = createMockReq("000000"); - const url = new URL("http://localhost/api/test"); - - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); - }); - - it("rejects requests with no PIN", () => { - const { svc } = createService("654321"); - const req = createMockReq(); - const url = new URL("http://localhost/api/test"); - - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); - }); - - it("accepts PIN via query string", () => { - const { svc } = createService("654321"); - const req = createMockReq(); - const url = new URL("http://localhost/api/test?pin=654321"); - - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(true); - }); - - it("locks out after repeated failures", () => { - const { svc } = createService("654321"); - const url = new URL("http://localhost/api/test"); - - for (let i = 0; i < 5; i++) { - const req = createMockReq("wrong", "10.0.0.50"); - svc.verifyHttpAuth(req, url); - } - - const req = createMockReq("654321", "10.0.0.50"); - const result = svc.verifyHttpAuth(req, url); - expect(result.allowed).toBe(false); - expect(result.lockedOut).toBe(true); - }); + function createMockReq( + headerPin?: string, + ip = "10.0.0.1", + ): import("http").IncomingMessage { + return { + headers: headerPin ? { "x-tasksync-pin": headerPin } : {}, + socket: { remoteAddress: ip }, + } as any; + } + + it("allows all requests when PIN is disabled", () => { + const { svc } = createService(); + svc.pinEnabled = false; + const req = createMockReq(); + const url = new URL("http://localhost/api/test"); + + expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); + }); + + it("allows requests with correct PIN header", () => { + const { svc } = createService("654321"); + const req = createMockReq("654321"); + const url = new URL("http://localhost/api/test"); + + expect(svc.verifyHttpAuth(req, url)).toEqual({ allowed: true }); + }); + + it("rejects requests with wrong PIN header", () => { + const { svc } = createService("654321"); + const req = createMockReq("000000"); + const url = new URL("http://localhost/api/test"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("rejects requests with no PIN", () => { + const { svc } = createService("654321"); + const req = createMockReq(); + const url = new URL("http://localhost/api/test"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + }); + + it("accepts PIN via query string", () => { + const { svc } = createService("654321"); + const req = createMockReq(); + const url = new URL("http://localhost/api/test?pin=654321"); + + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(true); + }); + + it("locks out after repeated failures", () => { + const { svc } = createService("654321"); + const url = new URL("http://localhost/api/test"); + + for (let i = 0; i < 5; i++) { + const req = createMockReq("wrong", "10.0.0.50"); + svc.verifyHttpAuth(req, url); + } + + const req = createMockReq("654321", "10.0.0.50"); + const result = svc.verifyHttpAuth(req, url); + expect(result.allowed).toBe(false); + expect(result.lockedOut).toBe(true); + }); }); // ─── getOrCreatePin ────────────────────────────────────────── describe("getOrCreatePin", () => { - it("generates a 6-digit PIN when none exists", () => { - const ctx = createMockContext(); - const svc = new RemoteAuthService(ctx); - - const pin = svc.getOrCreatePin(); - expect(pin).toMatch(/^\d{6}$/); - }); - - it("returns persisted PIN from globalState", () => { - const ctx = createMockContext(); - ctx.globalState.update("remotePin", "987654"); - const svc = new RemoteAuthService(ctx); - - expect(svc.getOrCreatePin()).toBe("987654"); - }); - - it("upgrades short PINs to 6 digits", () => { - const ctx = createMockContext(); - ctx.globalState.update("remotePin", "1234"); - const svc = new RemoteAuthService(ctx); - - const pin = svc.getOrCreatePin(); - expect(pin).toMatch(/^\d{6}$/); - expect(pin).not.toBe("1234"); - }); + it("generates a 6-digit PIN when none exists", () => { + const ctx = createMockContext(); + const svc = new RemoteAuthService(ctx); + + const pin = svc.getOrCreatePin(); + expect(pin).toMatch(/^\d{6}$/); + }); + + it("returns persisted PIN from globalState", () => { + const ctx = createMockContext(); + ctx.globalState.update("remotePin", "987654"); + const svc = new RemoteAuthService(ctx); + + expect(svc.getOrCreatePin()).toBe("987654"); + }); + + it("upgrades short PINs to 6 digits", () => { + const ctx = createMockContext(); + ctx.globalState.update("remotePin", "1234"); + const svc = new RemoteAuthService(ctx); + + const pin = svc.getOrCreatePin(); + expect(pin).toMatch(/^\d{6}$/); + expect(pin).not.toBe("1234"); + }); }); // ─── cleanup / removeClient / clearSessionTokens ──────────── describe("lifecycle methods", () => { - it("removeClient removes from authenticated set", () => { - const { svc } = createService(); - const ws = createMockWs(); - svc.authenticatedClients.add(ws as any); - - svc.removeClient(ws as any); - expect(svc.authenticatedClients.has(ws as any)).toBe(false); - }); - - it("clearSessionTokens clears all tokens", () => { - const { svc } = createService("123456"); - const ws = createMockWs(); - svc.handleAuth(ws as any, "10.0.0.1", "123456", undefined, GET_STATE, true); - - svc.clearSessionTokens(); - - // Token from earlier auth should no longer work - const token = ws._parsed()[0].sessionToken; - const ws2 = createMockWs(); - svc.handleAuth(ws2 as any, "10.0.0.1", undefined, token, GET_STATE, true); - expect(ws2._parsed()[0].type).toBe("authFailed"); - }); - - it("cleanup clears all state", () => { - const { svc } = createService(); - const ws = createMockWs(); - svc.authenticatedClients.add(ws as any); - - svc.cleanup(); - - expect(svc.authenticatedClients.size).toBe(0); - }); - - it("startFailedAttemptsCleanup does not throw when called twice", () => { - const { svc } = createService(); - svc.startFailedAttemptsCleanup(); - svc.startFailedAttemptsCleanup(); - svc.cleanup(); // clean up timer - }); + it("removeClient removes from authenticated set", () => { + const { svc } = createService(); + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.removeClient(ws as any); + expect(svc.authenticatedClients.has(ws as any)).toBe(false); + }); + + it("clearSessionTokens clears all tokens", () => { + const { svc } = createService("123456"); + const ws = createMockWs(); + svc.handleAuth(ws as any, "10.0.0.1", "123456", undefined, GET_STATE, true); + + svc.clearSessionTokens(); + + // Token from earlier auth should no longer work + const token = ws._parsed()[0].sessionToken; + const ws2 = createMockWs(); + svc.handleAuth(ws2 as any, "10.0.0.1", undefined, token, GET_STATE, true); + expect(ws2._parsed()[0].type).toBe("authFailed"); + }); + + it("cleanup clears all state", () => { + const { svc } = createService(); + const ws = createMockWs(); + svc.authenticatedClients.add(ws as any); + + svc.cleanup(); + + expect(svc.authenticatedClients.size).toBe(0); + }); + + it("startFailedAttemptsCleanup does not throw when called twice", () => { + const { svc } = createService(); + svc.startFailedAttemptsCleanup(); + svc.startFailedAttemptsCleanup(); + svc.cleanup(); // clean up timer + }); }); diff --git a/tasksync-chat/src/server/remoteAuthService.ts b/tasksync-chat/src/server/remoteAuthService.ts index ff64382..d7aa0fd 100644 --- a/tasksync-chat/src/server/remoteAuthService.ts +++ b/tasksync-chat/src/server/remoteAuthService.ts @@ -68,7 +68,7 @@ export class RemoteAuthService { return attempt; } - constructor(private context: vscode.ExtensionContext) { } + constructor(private context: vscode.ExtensionContext) {} /** * Handle PIN/session-token authentication for a WebSocket client. diff --git a/tasksync-chat/src/server/remoteHtmlService.ts b/tasksync-chat/src/server/remoteHtmlService.ts index f8b2e62..f4e221f 100644 --- a/tasksync-chat/src/server/remoteHtmlService.ts +++ b/tasksync-chat/src/server/remoteHtmlService.ts @@ -43,7 +43,7 @@ export class RemoteHtmlService { constructor( private webDir: string, private mediaDir: string, - ) { } + ) {} /** * Preload HTML templates asynchronously during server startup. diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index dd53b4c..c9cf88b 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -96,8 +96,8 @@ export async function loadPersistedHistoryFromDiskAsync(p: P): Promise { const parsed = JSON.parse(data); p._persistedHistory = Array.isArray(parsed.history) ? parsed.history - .filter((entry: ToolCallEntry) => entry.status === "completed") - .slice(0, p._MAX_HISTORY_ENTRIES) + .filter((entry: ToolCallEntry) => entry.status === "completed") + .slice(0, p._MAX_HISTORY_ENTRIES) : []; } catch (error) { console.error("[TaskSync] Failed to load persisted history:", error); diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index 9090a43..4f00a21 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -164,7 +164,7 @@ describe("handleAddQueuePrompt", () => { const resolve = vi.fn(); const pendingRequests = new Map(); pendingRequests.set("tc_1", resolve); - const timer = setTimeout(() => { }, 10000); + const timer = setTimeout(() => {}, 10000); const p = createMockP({ _queueEnabled: true, From ab3521a2bc15495dccc0d7b4782159a2ea13876f Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 02:26:52 +0400 Subject: [PATCH 07/35] fix: address second round of Copilot PR review feedback - handleSubmit: queue user input when no pending tool call (prevent silent drop) - gitService: reject absolute paths outside workspace root (security hardening) - serverUtils: import AttachmentInfo from webviewTypes (reduce coupling) - remoteSettingsHandler: use named WebSocket import for consistency - sessionManager: route sound errors through debugLog (reduce noise) - package.json: align engines.vscode with @types/vscode (^1.90.0) --- .../results.json | 1 + tasksync-chat/package.json | 2 +- .../server/gitService.comprehensive.test.ts | 6 ++-- tasksync-chat/src/server/gitService.test.ts | 6 ++-- tasksync-chat/src/server/gitService.ts | 15 ++++++++- .../src/server/remoteSettingsHandler.ts | 2 +- tasksync-chat/src/server/serverUtils.ts | 2 +- tasksync-chat/src/webview/messageRouter.ts | 33 ++++++++++++++----- tasksync-chat/src/webview/sessionManager.ts | 8 +++-- 9 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..db5aa2c --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.1","results":[[":tasksync-chat/dist/server/gitService.comprehensive.test.js",{"duration":0,"failed":true}],[":tasksync-chat/dist/server/serverUtils.test.js",{"duration":0,"failed":true}],[":tasksync-chat/dist/webview/webviewUtils.test.js",{"duration":0,"failed":true}],[":tasksync-chat/dist/webview/settingsHandlers.comprehensive.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/dist/webview/choiceParser.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/server/remoteAuthService.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/src/webview/choiceParser.test.ts",{"duration":10.912249999999972,"failed":false}],[":tasksync-chat/dist/webview/queueHandlers.comprehensive.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/server/gitService.comprehensive.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/dist/server/remoteAuthService.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/webview/webviewUtils.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/dist/constants/remoteConstants.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/server/serverUtils.test.ts",{"duration":192.16287499999999,"failed":false}],[":tasksync-chat/src/constants/remoteConstants.test.ts",{"duration":9.674958000000004,"failed":false}],[":tasksync-chat/dist/webview/queueHandlers.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/webview/queueHandlers.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/dist/server/gitService.test.js",{"duration":0,"failed":true}],[":tasksync-chat/dist/webview/settingsHandlers.test.js",{"duration":0,"failed":true}],[":tasksync-chat/dist/utils/imageUtils.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/server/gitService.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/dist/constants/fileExclusions.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/utils/imageUtils.test.ts",{"duration":4.2692499999999995,"failed":false}],[":tasksync-chat/src/constants/fileExclusions.test.ts",{"duration":6.429208000000017,"failed":false}],[":tasksync-chat/src/webview/settingsHandlers.test.ts",{"duration":0,"failed":true}],[":tasksync-chat/dist/utils/generateId.test.js",{"duration":0,"failed":true}],[":tasksync-chat/src/utils/generateId.test.ts",{"duration":4.606334000000004,"failed":false}],[":tasksync-chat/e2e/tests/remote-auth.spec.mjs",{"duration":0,"failed":true}]]} \ No newline at end of file diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 95a9078..0810c79 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -6,7 +6,7 @@ "icon": "media/Tasksync-logo.png", "version": "2.0.25", "engines": { - "vscode": "^1.99.0" + "vscode": "^1.90.0" }, "categories": [ "AI", diff --git a/tasksync-chat/src/server/gitService.comprehensive.test.ts b/tasksync-chat/src/server/gitService.comprehensive.test.ts index ed2b294..876d811 100644 --- a/tasksync-chat/src/server/gitService.comprehensive.test.ts +++ b/tasksync-chat/src/server/gitService.comprehensive.test.ts @@ -433,16 +433,16 @@ describe("GitService getRepo multi-root", () => { const repo2 = createMockRepo(); const api = createMockGitAPI([repo1, repo2]); api.getRepository.mockImplementation((uri: any) => - uri?.fsPath === "/project-b/file.ts" ? repo2 : null, + uri?.fsPath === "/project-a/sub/file.ts" ? repo2 : null, ); setupGitExtension(api); await service.initialize(); - // getDiff with absolute path triggers getRepo(fileUri) + // getDiff with absolute path under workspace triggers getRepo(fileUri) (vscode.workspace as any).workspaceFolders = [ { uri: { fsPath: "/project-a" } }, ]; - await service.getDiff("/project-b/file.ts"); + await service.getDiff("/project-a/sub/file.ts"); expect(repo2.diffWithHEAD).toHaveBeenCalled(); }); diff --git a/tasksync-chat/src/server/gitService.test.ts b/tasksync-chat/src/server/gitService.test.ts index 25c8884..46ff8ba 100644 --- a/tasksync-chat/src/server/gitService.test.ts +++ b/tasksync-chat/src/server/gitService.test.ts @@ -13,9 +13,9 @@ describe("isValidFilePath", () => { expect(isValidFilePath("path/to/file.js")).toBe(true); }); - it("accepts absolute paths", () => { - expect(isValidFilePath("/home/user/file.txt")).toBe(true); - expect(isValidFilePath("/Users/dev/project/src/app.ts")).toBe(true); + it("rejects absolute paths (no workspace root in test)", () => { + expect(isValidFilePath("/home/user/file.txt")).toBe(false); + expect(isValidFilePath("/Users/dev/project/src/app.ts")).toBe(false); }); it("accepts paths with dots (non-traversal)", () => { diff --git a/tasksync-chat/src/server/gitService.ts b/tasksync-chat/src/server/gitService.ts index 8dd4ae7..06836cf 100644 --- a/tasksync-chat/src/server/gitService.ts +++ b/tasksync-chat/src/server/gitService.ts @@ -43,6 +43,7 @@ export interface GitChanges { /** * Validate file paths to prevent command injection. * Rejects paths with shell metacharacters or attempted traversal. + * Absolute paths are only allowed if they resolve under the workspace root. */ export function isValidFilePath(filePath: string): boolean { // Reject empty/whitespace-only paths @@ -52,7 +53,19 @@ export function isValidFilePath(filePath: string): boolean { // Reject paths with shell metacharacters (allow path separators) const dangerousChars = /[`$|;&<>(){}[\]!*?'"\n\r\x00]/; if (dangerousChars.test(normalizedPath)) return false; - // Reject absolute paths that escape workspace + + // Absolute paths must resolve under the workspace root + if (path.isAbsolute(filePath)) { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) return false; + const resolved = path.resolve(filePath); + const root = path.resolve(workspaceFolders[0].uri.fsPath); + const rel = path.relative(root, resolved); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false; + return true; + } + + // Relative paths: reject directory traversal that escapes if (normalizedPath.includes("..")) { const normalized = path.normalize(normalizedPath); if (normalized.startsWith("..")) return false; diff --git a/tasksync-chat/src/server/remoteSettingsHandler.ts b/tasksync-chat/src/server/remoteSettingsHandler.ts index 4a7d581..a3256da 100644 --- a/tasksync-chat/src/server/remoteSettingsHandler.ts +++ b/tasksync-chat/src/server/remoteSettingsHandler.ts @@ -2,7 +2,7 @@ * Remote settings message dispatcher. * Routes settings-related WebSocket messages to the provider's settings handlers. */ -import type WebSocket from "ws"; +import type { WebSocket } from "ws"; import { ErrorCode } from "../constants/remoteConstants"; import * as settingsH from "../webview/settingsHandlers"; import type { P } from "../webview/webviewTypes"; diff --git a/tasksync-chat/src/server/serverUtils.ts b/tasksync-chat/src/server/serverUtils.ts index b649c49..0f5022f 100644 --- a/tasksync-chat/src/server/serverUtils.ts +++ b/tasksync-chat/src/server/serverUtils.ts @@ -9,7 +9,7 @@ import { MAX_ATTACHMENT_URI_LENGTH, MAX_ATTACHMENTS, } from "../constants/remoteConstants"; -import type { AttachmentInfo } from "../webview/webviewProvider"; +import type { AttachmentInfo } from "../webview/webviewTypes"; /** * Send a typed error response over a WebSocket connection. diff --git a/tasksync-chat/src/webview/messageRouter.ts b/tasksync-chat/src/webview/messageRouter.ts index 672bfc7..97f2ca2 100644 --- a/tasksync-chat/src/webview/messageRouter.ts +++ b/tasksync-chat/src/webview/messageRouter.ts @@ -343,13 +343,30 @@ export function handleSubmit( notifyQueueChanged(p); } } - // NOTE: Temp images are NOT cleaned up here anymore. - // They are stored in the ToolCallEntry.attachments and will be cleaned up when: - // 1. clearCurrentSession() is called - // 2. dispose() is called (extension deactivation) - - // Clear attachments after submit and sync with webview - p._attachments = []; - p._updateAttachmentsUI(); + } else { + debugLog( + "[TaskSync] handleSubmit — no pending tool call, queueing message", + ); + // No active tool call - queue message if it has content so it is not dropped + if (value && value.trim()) { + const queuedPrompt: QueuedPrompt = { + id: generateId("q"), + prompt: value.trim(), + attachments: attachments.length > 0 ? [...attachments] : undefined, + }; + p._promptQueue.push(queuedPrompt); + // Auto-switch to queue mode so user sees their message went to queue + p._queueEnabled = true; + notifyQueueChanged(p); + } } + + // NOTE: Temp images are NOT cleaned up here anymore. + // They are stored in the ToolCallEntry.attachments and will be cleaned up when: + // 1. clearCurrentSession() is called + // 2. dispose() is called (extension deactivation) + + // Clear attachments after submit and sync with webview + p._attachments = []; + p._updateAttachmentsUI(); } diff --git a/tasksync-chat/src/webview/sessionManager.ts b/tasksync-chat/src/webview/sessionManager.ts index 9707f46..30c8325 100644 --- a/tasksync-chat/src/webview/sessionManager.ts +++ b/tasksync-chat/src/webview/sessionManager.ts @@ -121,7 +121,11 @@ export function stopSessionTimerInterval(p: P): void { export function playSystemSound(): void { const platform = process.platform; const onErr = (err: Error | null) => { - if (err) console.error("[TaskSync] Sound playback error:", err.message); + if (err) + debugLog( + "[TaskSync] playSystemSound — sound playback error:", + err.message, + ); }; try { @@ -140,6 +144,6 @@ export function playSystemSound(): void { ); } } catch (e) { - console.error("[TaskSync] Sound playback error:", e); + debugLog("[TaskSync] playSystemSound — sound playback error:", e); } } From f9b130eddd8df5620f6df033fc293603c56cd0db Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 10:49:59 +0400 Subject: [PATCH 08/35] fix: address third round of Copilot PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use path.isAbsolute() instead of startsWith('/') for cross-platform path checks in gitService - Make PIN comparison genuinely timing-safe via SHA-256 digests in remoteAuthService - Replace O(n²) reduce() with O(1) running counter for terminal output truncation - Watch remoteConstants.ts in esbuild watch mode to keep shared constants SSOT in sync - Strip port/brackets from host before IP detection in cert SAN generation --- tasksync-chat/esbuild.js | 195 +++++++++++------- tasksync-chat/package.json | 2 +- tasksync-chat/src/context/terminalContext.ts | 8 +- tasksync-chat/src/server/gitService.ts | 14 +- tasksync-chat/src/server/remoteAuthService.ts | 16 +- tasksync-chat/src/server/serverUtils.ts | 20 +- 6 files changed, 153 insertions(+), 102 deletions(-) diff --git a/tasksync-chat/esbuild.js b/tasksync-chat/esbuild.js index 33e8d86..0e5ee2b 100644 --- a/tasksync-chat/esbuild.js +++ b/tasksync-chat/esbuild.js @@ -1,8 +1,8 @@ -const esbuild = require('esbuild'); -const fs = require('fs'); -const path = require('path'); +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); -const watch = process.argv.includes('--watch'); +const watch = process.argv.includes("--watch"); // ==================== Shared Constants Generation ==================== // Generates web/shared-constants.js from src/constants/remoteConstants.ts @@ -10,13 +10,15 @@ const watch = process.argv.includes('--watch'); function generateSharedConstants() { const source = fs.readFileSync( - path.join(__dirname, 'src', 'constants', 'remoteConstants.ts'), 'utf8', + path.join(__dirname, "src", "constants", "remoteConstants.ts"), + "utf8", ); // Extract simple numeric constants: export const NAME = ; function extractNum(name) { const m = source.match(new RegExp(`export const ${name}\\s*=\\s*(\\d+)`)); - if (!m) throw new Error(`Failed to extract ${name} from remoteConstants.ts`); + if (!m) + throw new Error(`Failed to extract ${name} from remoteConstants.ts`); return Number(m[1]); } @@ -24,22 +26,27 @@ function generateSharedConstants() { const timeoutMatch = source.match( /RESPONSE_TIMEOUT_ALLOWED_VALUES\s*=\s*new Set\(\[\s*([\d,\s]+)\s*\]\)/, ); - if (!timeoutMatch) throw new Error('Failed to extract RESPONSE_TIMEOUT_ALLOWED_VALUES'); - const timeoutValues = timeoutMatch[1].split(',').map(s => s.trim()).filter(Boolean).join(', '); + if (!timeoutMatch) + throw new Error("Failed to extract RESPONSE_TIMEOUT_ALLOWED_VALUES"); + const timeoutValues = timeoutMatch[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .join(", "); const v = { - protocolVersion: extractNum('WS_PROTOCOL_VERSION'), - timeoutDefault: extractNum('RESPONSE_TIMEOUT_DEFAULT_MINUTES'), - sessionWarningDefault: extractNum('DEFAULT_SESSION_WARNING_HOURS'), - sessionWarningMax: extractNum('SESSION_WARNING_HOURS_MAX'), - maxAutoDefault: extractNum('DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES'), - maxAutoLimit: extractNum('MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT'), - delayMinDefault: extractNum('DEFAULT_HUMAN_LIKE_DELAY_MIN'), - delayMaxDefault: extractNum('DEFAULT_HUMAN_LIKE_DELAY_MAX'), - delayMinLower: extractNum('HUMAN_DELAY_MIN_LOWER'), - delayMinUpper: extractNum('HUMAN_DELAY_MIN_UPPER'), - delayMaxLower: extractNum('HUMAN_DELAY_MAX_LOWER'), - delayMaxUpper: extractNum('HUMAN_DELAY_MAX_UPPER'), + protocolVersion: extractNum("WS_PROTOCOL_VERSION"), + timeoutDefault: extractNum("RESPONSE_TIMEOUT_DEFAULT_MINUTES"), + sessionWarningDefault: extractNum("DEFAULT_SESSION_WARNING_HOURS"), + sessionWarningMax: extractNum("SESSION_WARNING_HOURS_MAX"), + maxAutoDefault: extractNum("DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES"), + maxAutoLimit: extractNum("MAX_CONSECUTIVE_AUTO_RESPONSES_LIMIT"), + delayMinDefault: extractNum("DEFAULT_HUMAN_LIKE_DELAY_MIN"), + delayMaxDefault: extractNum("DEFAULT_HUMAN_LIKE_DELAY_MAX"), + delayMinLower: extractNum("HUMAN_DELAY_MIN_LOWER"), + delayMinUpper: extractNum("HUMAN_DELAY_MIN_UPPER"), + delayMaxLower: extractNum("HUMAN_DELAY_MAX_LOWER"), + delayMaxUpper: extractNum("HUMAN_DELAY_MAX_UPPER"), }; const output = `/** @@ -88,7 +95,7 @@ var TASKSYNC_HUMAN_DELAY_MAX_LOWER = ${v.delayMaxLower}; var TASKSYNC_HUMAN_DELAY_MAX_UPPER = ${v.delayMaxUpper}; `; - fs.writeFileSync(path.join(__dirname, 'web', 'shared-constants.js'), output); + fs.writeFileSync(path.join(__dirname, "web", "shared-constants.js"), output); } // ==================== Webview Build (concatenation) ==================== @@ -96,60 +103,63 @@ var TASKSYNC_HUMAN_DELAY_MAX_UPPER = ${v.delayMaxUpper}; // They share a single IIFE closure scope, so we concatenate them // in order and wrap with the IIFE boilerplate. -const WEBVIEW_SOURCE_DIR = path.join(__dirname, 'src', 'webview-ui'); -const WEBVIEW_OUTPUT = path.join(__dirname, 'media', 'webview.js'); +const WEBVIEW_SOURCE_DIR = path.join(__dirname, "src", "webview-ui"); +const WEBVIEW_OUTPUT = path.join(__dirname, "media", "webview.js"); const WEBVIEW_FILES = [ - 'constants.js', - 'adapter.js', - 'state.js', - 'init.js', - 'events.js', - 'history.js', - 'input.js', - 'messageHandler.js', - 'markdownUtils.js', - 'rendering.js', - 'queue.js', - 'approval.js', - 'settings.js', - 'slashCommands.js', - 'extras.js', + "constants.js", + "adapter.js", + "state.js", + "init.js", + "events.js", + "history.js", + "input.js", + "messageHandler.js", + "markdownUtils.js", + "rendering.js", + "queue.js", + "approval.js", + "settings.js", + "slashCommands.js", + "extras.js", ]; function buildWebview() { const header = [ - '/**', - ' * TaskSync Extension - Webview Script', - ' * Handles tool call history, prompt queue, attachments, and file autocomplete', - ' * ', - ' * Supports both VS Code webview (postMessage) and Remote PWA (WebSocket) modes', - ' * ', - ' * Built from src/webview-ui/ — DO NOT EDIT DIRECTLY', - ' */', - '(function () {', - ].join('\n'); + "/**", + " * TaskSync Extension - Webview Script", + " * Handles tool call history, prompt queue, attachments, and file autocomplete", + " * ", + " * Supports both VS Code webview (postMessage) and Remote PWA (WebSocket) modes", + " * ", + " * Built from src/webview-ui/ — DO NOT EDIT DIRECTLY", + " */", + "(function () {", + ].join("\n"); const footer = [ - '', - ' if (document.readyState === \'loading\') {', - ' document.addEventListener(\'DOMContentLoaded\', init);', - ' } else {', - ' init();', - ' }', - '}());', - '', - ].join('\n'); - - let body = ''; + "", + " if (document.readyState === 'loading') {", + " document.addEventListener('DOMContentLoaded', init);", + " } else {", + " init();", + " }", + "}());", + "", + ].join("\n"); + + let body = ""; for (const file of WEBVIEW_FILES) { - const content = fs.readFileSync(path.join(WEBVIEW_SOURCE_DIR, file), 'utf8'); + const content = fs.readFileSync( + path.join(WEBVIEW_SOURCE_DIR, file), + "utf8", + ); body += content; // Ensure each file ends with a newline for clean separation - if (!content.endsWith('\n')) body += '\n'; + if (!content.endsWith("\n")) body += "\n"; } - fs.writeFileSync(WEBVIEW_OUTPUT, header + '\n' + body + footer); + fs.writeFileSync(WEBVIEW_OUTPUT, header + "\n" + body + footer); } // ==================== Main Build ==================== @@ -157,39 +167,39 @@ function buildWebview() { async function main() { // Generate shared constants from SSOT (remoteConstants.ts → web/shared-constants.js) generateSharedConstants(); - console.log('Shared constants generated'); + console.log("Shared constants generated"); // Build webview (concatenation, fast) buildWebview(); - console.log('Webview build complete'); + console.log("Webview build complete"); // Copy mermaid.min.js to media/ for local serving (no CDN dependency) fs.copyFileSync( - path.join(__dirname, 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'), - path.join(__dirname, 'media', 'mermaid.min.js'), + path.join(__dirname, "node_modules", "mermaid", "dist", "mermaid.min.js"), + path.join(__dirname, "media", "mermaid.min.js"), ); - console.log('Mermaid copied to media/'); + console.log("Mermaid copied to media/"); // Build extension (esbuild, TypeScript bundling) const ctx = await esbuild.context({ - entryPoints: ['src/extension.ts'], + entryPoints: ["src/extension.ts"], bundle: true, - outfile: 'dist/extension.js', - external: ['vscode'], - format: 'cjs', - platform: 'node', - target: 'node18', + outfile: "dist/extension.js", + external: ["vscode"], + format: "cjs", + platform: "node", + target: "node18", sourcemap: true, minify: !watch, // Handle ESM packages with .js extensions - mainFields: ['module', 'main'], - conditions: ['import', 'node'], - resolveExtensions: ['.ts', '.js', '.mjs'], + mainFields: ["module", "main"], + conditions: ["import", "node"], + resolveExtensions: [".ts", ".js", ".mjs"], }); if (watch) { await ctx.watch(); - console.log('Watching extension for changes...'); + console.log("Watching extension for changes..."); // Also watch webview source files for changes const debounceTimers = {}; @@ -203,16 +213,41 @@ async function main() { buildWebview(); console.log(`Webview rebuilt (${file} changed)`); } catch (e) { - console.error('Webview build error:', e.message); + console.error("Webview build error:", e.message); } }, 50); }); } - console.log('Watching webview source for changes...'); + console.log("Watching webview source for changes..."); + + // Watch shared constants SSOT so remote/PWA stays in sync during dev + const remoteConstantsPath = path.join( + __dirname, + "src", + "constants", + "remoteConstants.ts", + ); + let remoteConstantsTimer; + fs.watch(remoteConstantsPath, () => { + clearTimeout(remoteConstantsTimer); + remoteConstantsTimer = setTimeout(() => { + try { + generateSharedConstants(); + console.log( + "Shared constants regenerated (remoteConstants.ts changed)", + ); + buildWebview(); + console.log("Webview rebuilt (shared constants changed)"); + } catch (e) { + console.error("Error regenerating shared constants:", e.message); + } + }, 100); + }); + console.log("Watching shared constants for changes..."); } else { await ctx.rebuild(); await ctx.dispose(); - console.log('Build complete'); + console.log("Build complete"); } } diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 0810c79..80e07ce 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -398,4 +398,4 @@ "typescript": "^5.3.3", "vitest": "^4.0.18" } -} +} \ No newline at end of file diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index fdb97f2..5ab79e2 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -147,15 +147,13 @@ export class TerminalContextProvider implements vscode.Disposable { ): Promise { try { const stream = execution.read(); + let outputSize = 0; for await (const data of stream) { tracker.output.push(data); + outputSize += data.length; // Limit output size to prevent memory issues (max 50KB per command) - const totalLength = tracker.output.reduce( - (sum, s) => sum + s.length, - 0, - ); - if (totalLength > this._MAX_OUTPUT_BYTES) { + if (outputSize > this._MAX_OUTPUT_BYTES) { tracker.output.push("\n... (output truncated)"); break; } diff --git a/tasksync-chat/src/server/gitService.ts b/tasksync-chat/src/server/gitService.ts index 06836cf..29a35cf 100644 --- a/tasksync-chat/src/server/gitService.ts +++ b/tasksync-chat/src/server/gitService.ts @@ -143,7 +143,7 @@ export class GitService { } async getDiff(filePath: string): Promise { - const fileUri = filePath.startsWith("/") + const fileUri = path.isAbsolute(filePath) ? vscode.Uri.file(filePath) : undefined; const repo = this.getRepo(fileUri); @@ -155,7 +155,7 @@ export class GitService { } // diffWithHEAD expects relative path - const relativePath = filePath.startsWith("/") + const relativePath = path.isAbsolute(filePath) ? vscode.workspace.asRelativePath(filePath) : filePath; @@ -174,7 +174,7 @@ export class GitService { } async stage(filePath: string): Promise { - const fileUri = filePath.startsWith("/") + const fileUri = path.isAbsolute(filePath) ? vscode.Uri.file(filePath) : undefined; const repo = this.getRepo(fileUri); @@ -182,7 +182,7 @@ export class GitService { if (!workspaceRoot) throw new Error("No workspace folder"); // Git add expects relative paths - const relativePath = filePath.startsWith("/") + const relativePath = path.isAbsolute(filePath) ? vscode.workspace.asRelativePath(filePath) : filePath; @@ -208,7 +208,7 @@ export class GitService { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!workspaceRoot) throw new Error("No workspace folder"); - const relativePath = filePath.startsWith("/") + const relativePath = path.isAbsolute(filePath) ? vscode.workspace.asRelativePath(filePath) : filePath; @@ -236,11 +236,11 @@ export class GitService { } async discard(filePath: string): Promise { - const fileUri = filePath.startsWith("/") + const fileUri = path.isAbsolute(filePath) ? vscode.Uri.file(filePath) : undefined; const repo = this.getRepo(fileUri); - const relativePath = filePath.startsWith("/") + const relativePath = path.isAbsolute(filePath) ? vscode.workspace.asRelativePath(filePath) : filePath; diff --git a/tasksync-chat/src/server/remoteAuthService.ts b/tasksync-chat/src/server/remoteAuthService.ts index d7aa0fd..e7f66c4 100644 --- a/tasksync-chat/src/server/remoteAuthService.ts +++ b/tasksync-chat/src/server/remoteAuthService.ts @@ -17,13 +17,17 @@ export class RemoteAuthService { pin: string = ""; readonly authenticatedClients: Set = new Set(); - /** Timing-safe PIN comparison to prevent timing attacks. */ + /** Timing-safe PIN comparison using SHA-256 digests to prevent timing attacks. */ private comparePinTimingSafe(input: string): boolean { - if (input.length !== this.pin.length) return false; - const pinBuffer = Buffer.from(this.pin, "utf8"); - const inputBuffer = Buffer.from(input, "utf8"); - if (pinBuffer.length !== inputBuffer.length) return false; - return crypto.timingSafeEqual(pinBuffer, inputBuffer); + const pinDigest = crypto + .createHash("sha256") + .update(this.pin, "utf8") + .digest(); + const inputDigest = crypto + .createHash("sha256") + .update(input, "utf8") + .digest(); + return crypto.timingSafeEqual(pinDigest, inputDigest); } private failedAttempts: Map< string, diff --git a/tasksync-chat/src/server/serverUtils.ts b/tasksync-chat/src/server/serverUtils.ts index 0f5022f..bb2cc02 100644 --- a/tasksync-chat/src/server/serverUtils.ts +++ b/tasksync-chat/src/server/serverUtils.ts @@ -171,10 +171,24 @@ export async function generateSelfSignedCert(host: string): Promise { const notBefore = new Date(); const notAfter = new Date(); notAfter.setFullYear(notAfter.getFullYear() + 1); - const isIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":"); + // Strip port and brackets to get the bare hostname/IP + let bareHost = host; + if (bareHost.startsWith("[")) { + // Bracketed IPv6: [::1]:3580 → ::1 + const closeBracket = bareHost.indexOf("]"); + if (closeBracket !== -1) bareHost = bareHost.slice(1, closeBracket); + } else if (bareHost.includes(":") && !bareHost.includes("::")) { + // host:port (but not IPv6 like ::1) + const lastColon = bareHost.lastIndexOf(":"); + const maybePart = bareHost.slice(lastColon + 1); + if (/^\d+$/.test(maybePart)) bareHost = bareHost.slice(0, lastColon); + } + const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(bareHost); + const isIPv6 = bareHost.includes(":"); + const isIP = isIPv4 || isIPv6; const altNames = isIP - ? [{ type: 7 as const, ip: host }] - : [{ type: 2 as const, value: host }]; + ? [{ type: 7 as const, ip: bareHost }] + : [{ type: 2 as const, value: bareHost }]; const pems = await selfsigned.generate(attrs, { keySize: 2048, notBeforeDate: notBefore, From 9bc6adae2c51108f597aa429b556538f0ab688d0 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 10:55:55 +0400 Subject: [PATCH 09/35] refactor: DRY gitService resolveFilePath helper, add cert host-parsing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract resolveFilePath() helper in gitService.ts to eliminate repeated path.isAbsolute/fileUri/relativePath/validation pattern across getDiff, stage, unstage, and discard (4 call sites → 1 helper) - Add 3 tests for generateSelfSignedCert host parsing: hostname:port, [::1]:port, plain hostname - Fix discard test setup to include workspaceFolders mock - 384/384 tests passing, build/tsc/biome clean --- .../server/gitService.comprehensive.test.ts | 4 ++ tasksync-chat/src/server/gitService.ts | 72 +++++-------------- tasksync-chat/src/server/serverUtils.test.ts | 17 +++++ 3 files changed, 39 insertions(+), 54 deletions(-) diff --git a/tasksync-chat/src/server/gitService.comprehensive.test.ts b/tasksync-chat/src/server/gitService.comprehensive.test.ts index 876d811..5ce30b5 100644 --- a/tasksync-chat/src/server/gitService.comprehensive.test.ts +++ b/tasksync-chat/src/server/gitService.comprehensive.test.ts @@ -333,6 +333,10 @@ describe("GitService.discard", () => { const api = createMockGitAPI([repo]); setupGitExtension(api); await service.initialize(); + + (vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: "/workspace" } }, + ]; }); it("discards changes for relative path", async () => { diff --git a/tasksync-chat/src/server/gitService.ts b/tasksync-chat/src/server/gitService.ts index 29a35cf..e16c68d 100644 --- a/tasksync-chat/src/server/gitService.ts +++ b/tasksync-chat/src/server/gitService.ts @@ -142,28 +142,30 @@ export class GitService { }; } - async getDiff(filePath: string): Promise { + /** + * Resolve a file path to a repo, workspace root, and validated relative path. + * Shared by getDiff, stage, unstage, and discard to avoid repetition. + */ + private resolveFilePath(filePath: string): { + repo: Repository; + workspaceRoot: string; + relativePath: string; + } { const fileUri = path.isAbsolute(filePath) ? vscode.Uri.file(filePath) : undefined; const repo = this.getRepo(fileUri); - - // Find workspace root to resolve relative path const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) { - throw new Error("No workspace folder"); - } - - // diffWithHEAD expects relative path + if (!workspaceRoot) throw new Error("No workspace folder"); const relativePath = path.isAbsolute(filePath) ? vscode.workspace.asRelativePath(filePath) : filePath; + if (!isValidFilePath(relativePath)) throw new Error("Invalid file path"); + return { repo, workspaceRoot, relativePath }; + } - // Validate path - if (!isValidFilePath(relativePath)) { - throw new Error("Invalid file path"); - } - + async getDiff(filePath: string): Promise { + const { repo, relativePath } = this.resolveFilePath(filePath); try { return await repo.diffWithHEAD(relativePath); } catch (err) { @@ -174,23 +176,7 @@ export class GitService { } async stage(filePath: string): Promise { - const fileUri = path.isAbsolute(filePath) - ? vscode.Uri.file(filePath) - : undefined; - const repo = this.getRepo(fileUri); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) throw new Error("No workspace folder"); - - // Git add expects relative paths - const relativePath = path.isAbsolute(filePath) - ? vscode.workspace.asRelativePath(filePath) - : filePath; - - // Validate path - if (!isValidFilePath(relativePath)) { - throw new Error("Invalid file path"); - } - + const { repo, relativePath } = this.resolveFilePath(filePath); await repo.add([relativePath]); } @@ -205,17 +191,7 @@ export class GitService { } async unstage(filePath: string): Promise { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) throw new Error("No workspace folder"); - - const relativePath = path.isAbsolute(filePath) - ? vscode.workspace.asRelativePath(filePath) - : filePath; - - // Validate path to prevent any malicious input - if (!isValidFilePath(relativePath)) { - throw new Error("Invalid file path"); - } + const { workspaceRoot, relativePath } = this.resolveFilePath(filePath); // Use spawn with arguments array to completely avoid shell injection const { spawn } = await import("node:child_process"); @@ -236,19 +212,7 @@ export class GitService { } async discard(filePath: string): Promise { - const fileUri = path.isAbsolute(filePath) - ? vscode.Uri.file(filePath) - : undefined; - const repo = this.getRepo(fileUri); - const relativePath = path.isAbsolute(filePath) - ? vscode.workspace.asRelativePath(filePath) - : filePath; - - // Validate path - if (!isValidFilePath(relativePath)) { - throw new Error("Invalid file path"); - } - + const { repo, relativePath } = this.resolveFilePath(filePath); await repo.clean([relativePath]); } diff --git a/tasksync-chat/src/server/serverUtils.test.ts b/tasksync-chat/src/server/serverUtils.test.ts index 484486c..bc8f979 100644 --- a/tasksync-chat/src/server/serverUtils.test.ts +++ b/tasksync-chat/src/server/serverUtils.test.ts @@ -416,4 +416,21 @@ describe("generateSelfSignedCert", () => { expect(result.key).toContain("PRIVATE KEY"); expect(result.cert).toContain("CERTIFICATE"); }); + + it("generates valid cert for hostname with port stripped", async () => { + const result = await generateSelfSignedCert("localhost:3580"); + expect(result.key).toContain("PRIVATE KEY"); + expect(result.cert).toContain("CERTIFICATE"); + }); + + it("generates valid cert for bracketed IPv6 with port", async () => { + const result = await generateSelfSignedCert("[::1]:3580"); + expect(result.key).toContain("PRIVATE KEY"); + expect(result.cert).toContain("CERTIFICATE"); + }); + + it("generates valid cert for plain hostname", async () => { + const result = await generateSelfSignedCert("myhost.local"); + expect(result.cert).toContain("CERTIFICATE"); + }); }); From b45bf23bf677db4c33b1ce95a6d2c3a2dadf08c6 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:00:22 +0400 Subject: [PATCH 10/35] docs: add .github/copilot-instructions.md for Copilot code review Repository-wide custom instructions for GitHub Copilot including: - Build, test, and validation commands - Code style conventions (TypeScript strict, Biome, tabs, double quotes) - SSOT/DRY/KISS/YAGNI principles with codebase-specific examples - Security best practices (OWASP Top 10, timing-safe comparison, path traversal prevention, command injection prevention, TLS certs) - Testing strategy (~98% coverage, 384+ tests) - Architecture notes (handler pattern, session state, MCP server) --- .github/copilot-instructions.md | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..dfc12dd --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,95 @@ +# TaskSync — Copilot Repository Instructions + +## What This Repository Does + +TaskSync is a human-in-the-loop workflow toolkit for AI-assisted development. The primary codebase is the VS Code extension in `tasksync-chat/`. It provides a sidebar with smart prompt queuing, Autopilot mode, an MCP server, and remote access via WebSocket. + +## Project Layout + +- `tasksync-chat/src/extension.ts` — Extension entry point +- `tasksync-chat/src/tools.ts` — VS Code language model tool definitions (`ask_user`) +- `tasksync-chat/src/webview/webviewProvider.ts` — Orchestrator: owns state, creates webview, delegates to handlers +- `tasksync-chat/src/webview/webviewTypes.ts` — Shared types (`P` interface, message unions) +- `tasksync-chat/src/webview/webviewUtils.ts` — Shared helpers (`debugLog()`, `mergeAndDedup()`, `notifyQueueChanged()`) +- `tasksync-chat/src/webview/*Handlers.ts` — Handler modules receive a `P` interface (no circular imports) +- `tasksync-chat/src/mcp/mcpServer.ts` — MCP server (Streamable HTTP, port 3579) +- `tasksync-chat/src/server/` — Remote server, auth, git operations, HTML service +- `tasksync-chat/src/constants/` — Shared constants (config keys, file exclusions, remote constants) +- `tasksync-chat/src/context/` — Context providers (files, terminal, problems) +- `tasksync-chat/src/utils/` — Utilities (ID generation, image handling) +- `tasksync-chat/esbuild.js` — Build script (extension, webview, shared constants, mermaid) +- `tasksync-chat/biome.json` — Biome linter/formatter config +- `tasksync-chat/vitest.config.ts` — Test config +- `tasksync-chat/web/` — Remote access PWA (login, service worker) +- `Prompt/` — Standalone prompt/protocol markdown files (not actively developed) + +## Build, Test, and Validate + +All commands run from `tasksync-chat/`: + +```bash +cd tasksync-chat +npm install # Always run first +node esbuild.js # Build → dist/extension.js, media/webview.js, web/shared-constants.js +npx tsc --noEmit # Type-check (must produce 0 errors) +npx vitest run # Run all tests (384+ tests, must all pass) +npm run lint # Biome lint (must produce 0 issues) +``` + +Always run these four checks (build, tsc, vitest, lint) after making changes. The CI workflow (`.github/workflows/auto-release.yml`) runs on pushes to `main` — it installs deps, builds, version-bumps, packages VSIX, and creates a GitHub release. + +Build output: `dist/` (extension bundle), `media/webview.js` (webview bundle), `web/shared-constants.js` (auto-generated for remote PWA). + +## Code Style and Conventions + +- **TypeScript** with `"strict": true`, ES2022 target, CommonJS modules +- **Indentation:** Tabs (enforced by Biome) +- **Quotes:** Double quotes (enforced by Biome) +- **Imports:** Auto-organized by Biome (`organizeImports: on`) +- **Type assertions:** Use `satisfies` over `as` (e.g., `} satisfies ToWebviewMessage`). `satisfies` validates shape at compile time; `as` silently bypasses checks. +- **Async I/O:** Always prefer async file operations over synchronous equivalents. Never use synchronous blocking calls on the extension host. +- **Logging:** Use `debugLog()` from `webviewUtils.ts` (gated behind `tasksync.debugLogging` config). Use `console.error` only for genuine errors. Never use `console.log` or `console.warn` in production code. +- **Changelog:** Update `CHANGELOG.md` for every user-facing change using the format `## TaskSync vX.Y.Z (MM-DD-YY)` (two-digit year). + +## SSOT / DRY / KISS / YAGNI Principles + +These principles are mandatory for all changes: + +- **Single Source of Truth (SSOT):** Every concept, constant, type, or piece of logic must have exactly one canonical definition. Do not duplicate config keys, message types, validation logic, or shared helpers. Constants live in `src/constants/`. Shared types live in `webviewTypes.ts`. Shared helpers live in `webviewUtils.ts`. +- **Don't Repeat Yourself (DRY):** If logic is used in more than one place, extract it into a shared helper. Examples already in the codebase: `debugLog()`, `mergeAndDedup()`, `notifyQueueChanged()`, `hasQueuedItems()`, `resolveFilePath()` (gitService). When you see the same pattern in 3+ call sites, extract it. +- **Keep It Simple, Stupid (KISS):** Prefer the simplest solution that works. Do not add abstraction layers, configuration options, or indirection without clear justification. A small amount of duplication is acceptable if the alternative is a complex abstraction for only 2 call sites. +- **You Aren't Gonna Need It (YAGNI):** Do not add features, parameters, or code paths "just in case." Only implement what is needed for the current task. Do not design for hypothetical future requirements. +- **Handler pattern:** All handler modules (`*Handlers.ts`) receive a `P` interface — do not add direct imports from `webviewProvider.ts`. This prevents circular dependencies. + +## Security Best Practices + +Follow OWASP Top 10 principles. Specific patterns enforced in this codebase: + +- **Timing-safe comparison:** Use `crypto.timingSafeEqual` with fixed-length digests (SHA-256) for PIN/secret comparison — never early-return on length differences. See `remoteAuthService.ts` `comparePinTimingSafe()`. +- **Path traversal prevention:** All file paths from remote clients must be validated with `isValidFilePath()` in `gitService.ts` — rejects `..`, absolute paths (when no workspace), null bytes, backticks, and shells metacharacters. Use `path.isAbsolute()` instead of `startsWith("/")` for cross-platform correctness. +- **Command injection prevention:** Use `child_process.spawn` with argument arrays — never `exec` or string interpolation in shell commands. See `gitService.ts` `unstage()`. +- **No credentials in code:** Never commit secrets, API keys, or tokens. Auth uses ephemeral PINs with session tokens and lockout after failed attempts. +- **Input validation at boundaries:** Validate all user/remote input at the entry point. Trust internal code and framework guarantees — do not add redundant validation deep in call stacks. +- **TLS certificates:** `generateSelfSignedCert` strips ports and brackets from hosts before SAN detection. Always test IPv4, IPv6 (`::1`), bracketed IPv6 (`[::1]:port`), and hostname:port formats. +- **Security headers:** Set `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, and `X-XSS-Protection` on all HTTP responses. See `serverUtils.ts` `setSecurityHeaders()`. +- **Origin validation:** Check `Origin` and `Host` headers on WebSocket upgrade requests. See `serverUtils.ts` `isAllowedOrigin()`. + +## Testing Strategy + +- **Framework:** Vitest, 14 test files, 384+ tests, ~98% coverage +- **VS Code mock:** `src/__mocks__/vscode.ts` — mocks VS Code API for unit tests +- **Test setup:** Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` +- **Coverage requirement:** Maintain or improve coverage. Add tests for new logic, especially: + - Security-sensitive code (auth, path validation, input parsing) + - Edge cases in parsing (IPv6, ports, special characters) + - Error handling paths + +## Architecture Notes + +- The `ask_user` tool (registered in `tools.ts`, handled in `toolCallHandler.ts`) is the core interaction primitive. +- Queue, history, and settings are per-workspace (workspace-scoped storage with global fallback). +- Session state uses a boolean `sessionTerminated` flag — do not use string matching for termination detection. +- Debounced history saves (2 s) for disk I/O performance. +- Remote server uses WebSocket over HTTP with PIN-based auth and session tokens. +- MCP server uses Streamable HTTP transport with `/sse` backward-compat routing. +- `esbuild.js` watch mode monitors `remoteConstants.ts` changes and rebuilds shared constants with 100ms debounce. From 6d872a64e68a730417c2b3b427ba56aa7d551657 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:03:02 +0400 Subject: [PATCH 11/35] docs: add path-specific Copilot code review instructions Add .github/instructions/*.instructions.md files for Copilot code review: - typescript.instructions.md: TypeScript conventions, SSOT/DRY/KISS/YAGNI, logging, async I/O (applies to all .ts/.tsx files) - security.instructions.md: OWASP patterns, timing-safe comparison, path traversal, command injection, TLS, auth (applies to src/server/) - testing.instructions.md: Vitest patterns, coverage requirements, mock setup, edge case testing (applies to all .test.ts files) - webview.instructions.md: Handler P interface pattern, shared helpers, session state, storage scope (applies to src/webview/) --- .github/instructions/security.instructions.md | 39 +++++++++++++++++++ .github/instructions/testing.instructions.md | 25 ++++++++++++ .../instructions/typescript.instructions.md | 36 +++++++++++++++++ .github/instructions/webview.instructions.md | 25 ++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 .github/instructions/security.instructions.md create mode 100644 .github/instructions/testing.instructions.md create mode 100644 .github/instructions/typescript.instructions.md create mode 100644 .github/instructions/webview.instructions.md diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md new file mode 100644 index 0000000..9673f99 --- /dev/null +++ b/.github/instructions/security.instructions.md @@ -0,0 +1,39 @@ +--- +applyTo: "tasksync-chat/src/server/**" +--- + +# Security Standards (Server Code) + +All code in `src/server/` handles remote client connections and must follow strict security practices. + +## Timing-Safe Comparison +- Use `crypto.timingSafeEqual` with fixed-length SHA-256 digests for PIN/secret comparison +- Never early-return on length differences — hash both inputs first +- See `remoteAuthService.ts` `comparePinTimingSafe()` for the pattern + +## Path Traversal Prevention +- All file paths from remote clients must be validated with `isValidFilePath()` in `gitService.ts` +- Reject `..`, null bytes, backticks, and shell metacharacters +- Use `path.isAbsolute()` instead of `startsWith("/")` for cross-platform correctness +- Use the shared `resolveFilePath()` helper for getDiff/stage/unstage/discard operations + +## Command Injection Prevention +- Use `child_process.spawn` with argument arrays — never `exec` or string interpolation +- See `gitService.ts` `unstage()` for the correct pattern + +## Auth +- Auth uses ephemeral PINs with session tokens and lockout after failed attempts +- Never commit secrets, API keys, or tokens + +## Input Validation +- Validate all user/remote input at the entry point (system boundary) +- Trust internal code and framework guarantees — no redundant validation deep in call stacks + +## TLS Certificates +- `generateSelfSignedCert` strips ports and brackets from hosts before SAN detection +- Always test IPv4, IPv6 (`::1`), bracketed IPv6 (`[::1]:port`), and hostname:port formats + +## HTTP Security +- Set `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection` on all responses +- Check `Origin` and `Host` headers on WebSocket upgrade requests +- See `serverUtils.ts` `setSecurityHeaders()` and `isAllowedOrigin()` diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000..88b95c1 --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,25 @@ +--- +applyTo: "**/*.test.ts" +--- + +# Testing Standards + +## Framework +- Vitest with 384+ tests across 14 test files (~98% coverage) +- VS Code API is mocked in `src/__mocks__/vscode.ts` + +## Test Setup +- Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` +- Always call `vi.restoreAllMocks()` in `beforeEach` to prevent test pollution + +## Coverage +- Maintain or improve the current ~98% coverage +- Add tests for security-sensitive code (auth, path validation, input parsing) +- Add tests for edge cases (IPv6, ports, special characters, Windows paths) +- Add tests for error handling paths + +## Patterns +- Use `describe` blocks grouped by function or class method +- Use descriptive test names: "throws for invalid file path", "strips port from hostname" +- Test both happy paths and error conditions +- For async operations, use `await expect(...).rejects.toThrow()` diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md new file mode 100644 index 0000000..d4a7959 --- /dev/null +++ b/.github/instructions/typescript.instructions.md @@ -0,0 +1,36 @@ +--- +applyTo: "**/*.ts,**/*.tsx" +--- + +# TypeScript Conventions + +## Strict Mode +- TypeScript strict mode is enabled — do not use `any` or suppress type errors with `as` casts +- Use `satisfies` over `as` for message types (e.g., `} satisfies ToWebviewMessage`) +- `satisfies` validates shape at compile time; `as` silently bypasses checks + +## Style +- Indentation: tabs (enforced by Biome) +- Quotes: double quotes (enforced by Biome) +- Imports: auto-organized by Biome — do not manually reorder + +## Async I/O +- Always prefer async file operations over synchronous equivalents +- Never use synchronous blocking calls on the VS Code extension host + +## Logging +- Use `debugLog()` from `webviewUtils.ts` for debug output (gated behind `tasksync.debugLogging`) +- Use `console.error` only for genuine error/failure paths +- Never use `console.log` or `console.warn` in production code + +## SSOT / DRY +- Constants live in `src/constants/` — do not duplicate config keys +- Shared types live in `webviewTypes.ts` — one canonical definition per type +- Shared helpers live in `webviewUtils.ts` — extract repeated logic (3+ call sites) into helpers +- Handler modules (`*Handlers.ts`) receive a `P` interface — do not import from `webviewProvider.ts` directly + +## KISS / YAGNI +- Prefer the simplest solution that works +- Do not add abstraction layers without clear justification +- Do not add features or code paths "just in case" +- Small duplication is acceptable if the alternative is complex abstraction for only 2 call sites diff --git a/.github/instructions/webview.instructions.md b/.github/instructions/webview.instructions.md new file mode 100644 index 0000000..ee86e5d --- /dev/null +++ b/.github/instructions/webview.instructions.md @@ -0,0 +1,25 @@ +--- +applyTo: "tasksync-chat/src/webview/**" +--- + +# Webview Architecture + +## Handler Pattern +- All handler modules (`*Handlers.ts`) receive a `P` interface (from `webviewTypes.ts`) +- Do not import directly from `webviewProvider.ts` — this prevents circular dependencies +- `webviewProvider.ts` is the orchestrator: owns state, creates webview, delegates to handlers + +## Shared Helpers (DRY) +- Shared logic lives in `webviewUtils.ts`: `debugLog()`, `mergeAndDedup()`, `notifyQueueChanged()`, `hasQueuedItems()` +- When the same pattern appears in 3+ handler files, extract it to `webviewUtils.ts` + +## Types +- Shared types live in `webviewTypes.ts` — one canonical definition per message type +- Use `satisfies` for message type validation (e.g., `} satisfies ToWebviewMessage`) + +## Session State +- Uses a boolean `sessionTerminated` flag — do not use string matching for termination detection +- Debounced history saves (2 s) for disk I/O performance + +## Storage +- Queue, history, and settings are per-workspace (workspace-scoped storage with global fallback) From 3e071feb8f80b07ef3b2cadac6c61ca0b549b30f Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:04:29 +0400 Subject: [PATCH 12/35] docs: sync AGENTS.md with copilot-instructions.md - Add SSOT/DRY/KISS/YAGNI principles section - Expand Security section with OWASP patterns (timing-safe, path traversal, command injection, headers, origin validation) - Update test count from 381+ to 384+ with ~98% coverage - Add test setup guidance (workspaceFolders mock) - Add resolveFilePath() to DRY examples - Add build validation reminder --- AGENTS.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd5d5f8..5005ff7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,9 @@ npm install | Watch mode | `npm run watch` | | Package VSIX | `npx vsce package` | -> **Build output** goes to `dist/` (extension bundle) and `media/webview.js` (webview bundle). The build also auto-generates `web/shared-constants.js` for the remote PWA. +> **Build output** goes to `dist/` (extension bundle), `media/webview.js` (webview bundle), and `web/shared-constants.js` (auto-generated for the remote PWA). +> +> Always run build, tsc, vitest, and lint after making changes. --- @@ -102,7 +104,19 @@ npm install - **Type assertions:** Use `satisfies` over `as` for message types (e.g., `} satisfies ToWebviewMessage)`). The `satisfies` keyword validates shape at compile time; `as` silently bypasses checks. - **Async I/O:** Prefer async file operations over synchronous equivalents. - **Promises:** `IncomingRequest` objects must store both `resolve` and `reject` for proper cleanup on dispose. -- **DRY:** Shared logic goes in `webviewUtils.ts`. Examples: `debugLog()`, `mergeAndDedup()`, `notifyQueueChanged()`, `hasQueuedItems()`. +- **DRY:** Shared logic goes in `webviewUtils.ts`. Examples: `debugLog()`, `mergeAndDedup()`, `notifyQueueChanged()`, `hasQueuedItems()`, `resolveFilePath()` (gitService). + +--- + +## SSOT / DRY / KISS / YAGNI Principles + +These principles are mandatory for all changes: + +- **Single Source of Truth (SSOT):** Every concept, constant, type, or piece of logic must have exactly one canonical definition. Constants live in `src/constants/`. Shared types live in `webviewTypes.ts`. Shared helpers live in `webviewUtils.ts`. +- **Don't Repeat Yourself (DRY):** If logic is used in more than one place, extract it into a shared helper. When you see the same pattern in 3+ call sites, extract it. +- **Keep It Simple, Stupid (KISS):** Prefer the simplest solution that works. Do not add abstraction layers without clear justification. A small amount of duplication is acceptable if the alternative is a complex abstraction for only 2 call sites. +- **You Aren't Gonna Need It (YAGNI):** Do not add features, parameters, or code paths "just in case." Only implement what is needed for the current task. +- **Handler pattern:** All handler modules (`*Handlers.ts`) receive a `P` interface — do not add direct imports from `webviewProvider.ts`. This prevents circular dependencies. --- @@ -121,8 +135,10 @@ npm install ## Testing -- **Framework:** Vitest (14 test files, 381+ tests) +- **Framework:** Vitest (14 test files, 384+ tests, ~98% coverage) - **Mocks:** VS Code API is mocked in `src/__mocks__/vscode.ts` +- **Test setup:** Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` +- **Coverage:** Maintain or improve coverage. Add tests for security-sensitive code, edge cases, and error handling paths. - Run `npx vitest run` to execute all tests. Always verify tests pass after changes. --- @@ -135,6 +151,14 @@ npm install ## Security & Responsible Use -- Do not commit secrets or credentials. -- Do not introduce synchronous blocking calls on the VS Code extension host. -- No `console.log` or `console.warn` in production code — use `debugLog()` for debug output and `console.error` for genuine errors. +Follow OWASP Top 10 principles. Specific patterns enforced in this codebase: + +- **No credentials in code:** Never commit secrets, API keys, or tokens. +- **No blocking calls:** Do not introduce synchronous blocking calls on the VS Code extension host. +- **No console.log/warn:** Use `debugLog()` for debug output and `console.error` for genuine errors. +- **Timing-safe comparison:** Use `crypto.timingSafeEqual` with SHA-256 digests for PIN/secret comparison. See `remoteAuthService.ts`. +- **Path traversal prevention:** Validate all remote file paths with `isValidFilePath()` in `gitService.ts`. Use `path.isAbsolute()` instead of `startsWith("/")`. +- **Command injection prevention:** Use `child_process.spawn` with argument arrays — never `exec` or string interpolation. +- **Input validation at boundaries:** Validate all user/remote input at the entry point. Trust internal code deeper in call stacks. +- **Security headers:** Set CSP, X-Content-Type-Options, X-Frame-Options, and X-XSS-Protection on all HTTP responses. +- **Origin validation:** Check `Origin` and `Host` headers on WebSocket upgrade requests. From 9314309b734265e18375b41bbfad1276ba8fafb7 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:06:35 +0400 Subject: [PATCH 13/35] fix: handle undefined attachments in queue prompts and improve error handling for webview template loading --- tasksync-chat/src/context/terminalContext.ts | 8 ++++++-- tasksync-chat/src/server/remoteGitHandlers.ts | 4 ++-- tasksync-chat/src/webview/lifecycleHandlers.ts | 7 ++++++- tasksync-chat/src/webview/queueHandlers.ts | 4 ++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index 5ab79e2..2f0bf61 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -124,9 +124,13 @@ export class TerminalContextProvider implements vscode.Disposable { // Remove all execution trackers associated with this terminal const toDelete: vscode.TerminalShellExecution[] = []; + const hasTerminal = ( + exec: vscode.TerminalShellExecution, + ): exec is vscode.TerminalShellExecution & { terminal: vscode.Terminal } => + typeof (exec as unknown as Record).terminal !== "undefined"; + for (const [execution] of this._activeExecutions) { - // TerminalShellExecution has an undocumented .terminal property - if ("terminal" in execution && execution.terminal === terminal) { + if (hasTerminal(execution) && execution.terminal === terminal) { toDelete.push(execution); } } diff --git a/tasksync-chat/src/server/remoteGitHandlers.ts b/tasksync-chat/src/server/remoteGitHandlers.ts index 5612c10..d0802aa 100644 --- a/tasksync-chat/src/server/remoteGitHandlers.ts +++ b/tasksync-chat/src/server/remoteGitHandlers.ts @@ -179,7 +179,7 @@ export async function handleCommit( // Validate commit message const trimmed = (message || "").trim(); if (!trimmed || trimmed.length > MAX_COMMIT_MESSAGE_LENGTH) { - sendWsError(ws, "Invalid commit message"); + sendWsError(ws, "Invalid commit message", ErrorCode.INVALID_INPUT); return; } try { @@ -265,7 +265,7 @@ export async function dispatchGitMessage( gitServiceAvailable: boolean, broadcast: BroadcastFn, searchFn: (query: string) => Promise, - msg: { type: string; [key: string]: unknown }, + msg: { type: string;[key: string]: unknown }, ): Promise { switch (msg.type) { case "getChanges": diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index 923ac8d..2a6c285 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -25,7 +25,12 @@ export async function preloadBodyTemplate( "media", "webview-body.html", ).fsPath; - cachedBodyTemplate = await fs.promises.readFile(templatePath, "utf8"); + try { + cachedBodyTemplate = await fs.promises.readFile(templatePath, "utf8"); + } catch (err) { + console.error("Failed to preload webview body template:", err); + cachedBodyTemplate = undefined; + } } /** diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index e295411..79a6b36 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -70,7 +70,7 @@ export function handleAddQueuePrompt( let completedEntry: ToolCallEntry; if (pendingEntry && pendingEntry.status === "pending") { pendingEntry.response = queuedPrompt.prompt; - pendingEntry.attachments = queuedPrompt.attachments; + pendingEntry.attachments = queuedPrompt.attachments || []; pendingEntry.status = "completed"; pendingEntry.isFromQueue = true; pendingEntry.timestamp = Date.now(); @@ -80,7 +80,7 @@ export function handleAddQueuePrompt( id: currentCallId, prompt: "Tool call", response: queuedPrompt.prompt, - attachments: queuedPrompt.attachments, + attachments: queuedPrompt.attachments || [], timestamp: Date.now(), isFromQueue: true, status: "completed", From 2bbc6bef162966c366565bcef14e61b8cfddbb86 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:10:12 +0400 Subject: [PATCH 14/35] docs: add intentional-limitation comment to esbuild regex extractor --- tasksync-chat/esbuild.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tasksync-chat/esbuild.js b/tasksync-chat/esbuild.js index 0e5ee2b..906d2ce 100644 --- a/tasksync-chat/esbuild.js +++ b/tasksync-chat/esbuild.js @@ -15,6 +15,8 @@ function generateSharedConstants() { ); // Extract simple numeric constants: export const NAME = ; + // NOTE: This regex only supports integer literals. If a constant becomes an expression + // (e.g., 5 * 60 * 1000), extractNum() will throw at build time — this is intentional. function extractNum(name) { const m = source.match(new RegExp(`export const ${name}\\s*=\\s*(\\d+)`)); if (!m) From c2347d529ec20d948047cb4dee3fa13c4374ee44 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:20:15 +0400 Subject: [PATCH 15/35] fix: address round-5 Copilot review comments - tools.ts: validate params.question with runtime type check before use - package.json: soften 'ALWAYS include summary' to 'Include summary' (optional field) - queueHandlers.ts: skip notifyQueueChanged when remove is a no-op - lifecycleHandlers.ts: wrap sync readFileSync fallback in try/catch with safe HTML fallback - persistence.ts: replace fs.existsSync with idempotent mkdir({recursive:true}) - remoteApiHandlers.ts: enforce MAX_SEARCH_QUERY_LENGTH on searchFilesForRemote --- tasksync-chat/package.json | 2 +- tasksync-chat/src/tools.ts | 10 ++++++++-- tasksync-chat/src/webview/lifecycleHandlers.ts | 10 ++++++++-- tasksync-chat/src/webview/persistence.ts | 4 +--- tasksync-chat/src/webview/queueHandlers.ts | 13 +++++++++++-- tasksync-chat/src/webview/remoteApiHandlers.ts | 3 ++- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 80e07ce..4384bca 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -44,7 +44,7 @@ ], "toolReferenceName": "askUser", "displayName": "Ask User", - "modelDescription": "This is your ONLY communication channel with the user. The user CANNOT see your chat responses — they can ONLY see messages sent through this tool. You MUST call this tool in a continuous loop: 1) Call askUser to show your question/status to the user. 2) Receive the user's response. 3) Do the requested work. 4) Call askUser AGAIN with the results. 5) Repeat from step 2. NEVER end your turn without calling this tool — if you do, the user sees nothing and thinks you crashed. ALWAYS include a 'summary' field describing your work so the user has full context. If you completed a task, call this tool to report the results. If you need clarification, call this tool to ask. There is NO scenario where ending without calling this tool is acceptable.", + "modelDescription": "This is your ONLY communication channel with the user. The user CANNOT see your chat responses — they can ONLY see messages sent through this tool. You MUST call this tool in a continuous loop: 1) Call askUser to show your question/status to the user. 2) Receive the user's response. 3) Do the requested work. 4) Call askUser AGAIN with the results. 5) Repeat from step 2. NEVER end your turn without calling this tool — if you do, the user sees nothing and thinks you crashed. Include a 'summary' field describing your work so the user has full context. If you completed a task, call this tool to report the results. If you need clarification, call this tool to ask. There is NO scenario where ending without calling this tool is acceptable.", "canBeReferencedInPrompt": true, "icon": "$(question)", "inputSchema": { diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index fc84d07..a0510e3 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -193,13 +193,19 @@ export function registerTools( token: vscode.CancellationToken, ) { const params = options.input; + const safeQuestion = + typeof params?.question === "string" ? params.question : ""; debugLog( "[TaskSync] LM tool invoke — question:", - params.question.slice(0, 60), + safeQuestion.slice(0, 60), ); try { - const result = await askUser(params, provider, token); + const safeParams: Input = { + question: safeQuestion, + summary: params?.summary, + }; + const result = await askUser(safeParams, provider, token); // Build result parts - text first, then images const resultParts: ( diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index 2a6c285..aa4ea47 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -77,9 +77,15 @@ export function getHtmlContent( "webview-body.html", ).fsPath; if (!cachedBodyTemplate) { - cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); + try { + cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); + } catch (err) { + console.error("Failed to load webview body template (sync fallback):", err); + } } - let bodyHtml = cachedBodyTemplate; + let bodyHtml = + cachedBodyTemplate ?? + `

    TaskSync Chat

    Unable to load the webview template. Please reload the window.

    `; bodyHtml = bodyHtml .replace(/\{\{LOGO_URI\}\}/g, logoUri.toString()) diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index c9cf88b..a7bdf09 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -58,9 +58,7 @@ export async function saveQueueToDiskAsync(p: P): Promise { const storagePath = getStorageUri(p).fsPath; const queuePath = path.join(storagePath, "queue.json"); - if (!fs.existsSync(storagePath)) { - await fs.promises.mkdir(storagePath, { recursive: true }); - } + await fs.promises.mkdir(storagePath, { recursive: true }); const data = JSON.stringify( { diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index 79a6b36..a20c78f 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -136,13 +136,22 @@ export function handleAddQueuePrompt( */ export function handleRemoveQueuePrompt(p: P, promptId: string): void { if (!isValidQueueId(promptId)) return; + const beforeLength = p._promptQueue.length; debugLog( - `[TaskSync] handleRemoveQueuePrompt — promptId: ${promptId}, queueSize before: ${p._promptQueue.length}`, + `[TaskSync] handleRemoveQueuePrompt — promptId: ${promptId}, queueSize before: ${beforeLength}`, ); p._promptQueue = p._promptQueue.filter( (pr: QueuedPrompt) => pr.id !== promptId, ); - notifyQueueChanged(p); + if (p._promptQueue.length !== beforeLength) { + notifyQueueChanged(p); + } + if (p._promptQueue.length !== beforeLength) { + notifyQueueChanged(p); + } + if (p._promptQueue.length !== beforeLength) { + notifyQueueChanged(p); + } } /** diff --git a/tasksync-chat/src/webview/remoteApiHandlers.ts b/tasksync-chat/src/webview/remoteApiHandlers.ts index 03f1fed..0018840 100644 --- a/tasksync-chat/src/webview/remoteApiHandlers.ts +++ b/tasksync-chat/src/webview/remoteApiHandlers.ts @@ -14,6 +14,7 @@ import { MAX_QUEUE_PROMPT_LENGTH, MAX_QUEUE_SIZE, MAX_REMOTE_HISTORY_ITEMS, + MAX_SEARCH_QUERY_LENGTH, } from "../constants/remoteConstants"; import { isApprovalQuestion, parseChoices } from "./choiceParser"; import { searchToolsForAutocomplete } from "./fileHandlers"; @@ -355,7 +356,7 @@ export async function searchFilesForRemote( const toolResults = searchToolsForAutocomplete(query || ""); // File search requires at least 2 chars to avoid loading entire workspace - if (!query || query.length < 2) { + if (!query || query.length < 2 || query.length > MAX_SEARCH_QUERY_LENGTH) { return toolResults; } From c895401aa5af0c1fd1e14e0090958dc80f0dd7bb Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:24:28 +0400 Subject: [PATCH 16/35] fix: revert summary wording to ALWAYS, remove query length cap - Restore 'ALWAYS include a summary field' in modelDescription (intentional strong guidance for LMs) - Remove MAX_SEARCH_QUERY_LENGTH cap from searchFilesForRemote (VS Code findFiles already limits results to 50, glob escaping prevents injection) --- tasksync-chat/package.json | 2 +- .../src/webview/remoteApiHandlers.ts | 25 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 4384bca..80e07ce 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -44,7 +44,7 @@ ], "toolReferenceName": "askUser", "displayName": "Ask User", - "modelDescription": "This is your ONLY communication channel with the user. The user CANNOT see your chat responses — they can ONLY see messages sent through this tool. You MUST call this tool in a continuous loop: 1) Call askUser to show your question/status to the user. 2) Receive the user's response. 3) Do the requested work. 4) Call askUser AGAIN with the results. 5) Repeat from step 2. NEVER end your turn without calling this tool — if you do, the user sees nothing and thinks you crashed. Include a 'summary' field describing your work so the user has full context. If you completed a task, call this tool to report the results. If you need clarification, call this tool to ask. There is NO scenario where ending without calling this tool is acceptable.", + "modelDescription": "This is your ONLY communication channel with the user. The user CANNOT see your chat responses — they can ONLY see messages sent through this tool. You MUST call this tool in a continuous loop: 1) Call askUser to show your question/status to the user. 2) Receive the user's response. 3) Do the requested work. 4) Call askUser AGAIN with the results. 5) Repeat from step 2. NEVER end your turn without calling this tool — if you do, the user sees nothing and thinks you crashed. ALWAYS include a 'summary' field describing your work so the user has full context. If you completed a task, call this tool to report the results. If you need clarification, call this tool to ask. There is NO scenario where ending without calling this tool is acceptable.", "canBeReferencedInPrompt": true, "icon": "$(question)", "inputSchema": { diff --git a/tasksync-chat/src/webview/remoteApiHandlers.ts b/tasksync-chat/src/webview/remoteApiHandlers.ts index 0018840..0ced16f 100644 --- a/tasksync-chat/src/webview/remoteApiHandlers.ts +++ b/tasksync-chat/src/webview/remoteApiHandlers.ts @@ -14,7 +14,6 @@ import { MAX_QUEUE_PROMPT_LENGTH, MAX_QUEUE_SIZE, MAX_REMOTE_HISTORY_ITEMS, - MAX_SEARCH_QUERY_LENGTH, } from "../constants/remoteConstants"; import { isApprovalQuestion, parseChoices } from "./choiceParser"; import { searchToolsForAutocomplete } from "./fileHandlers"; @@ -78,17 +77,17 @@ export function getRemoteState(p: P): { pending: pendingEntry && pendingEntry.status === "pending" ? { - id: pendingEntry.id, - prompt: pendingEntry.prompt, - summary: pendingEntry.summary, - choices: parseChoices(pendingEntry.prompt).map((c) => ({ - label: c.label, - value: c.value, - shortLabel: c.shortLabel, - })), - isApproval: isApprovalQuestion(pendingEntry.prompt), - timestamp: pendingEntry.timestamp, - } + id: pendingEntry.id, + prompt: pendingEntry.prompt, + summary: pendingEntry.summary, + choices: parseChoices(pendingEntry.prompt).map((c) => ({ + label: c.label, + value: c.value, + shortLabel: c.shortLabel, + })), + isApproval: isApprovalQuestion(pendingEntry.prompt), + timestamp: pendingEntry.timestamp, + } : null, queue: p._promptQueue.map( (q: { id: string; prompt: string; attachments?: AttachmentInfo[] }) => ({ @@ -356,7 +355,7 @@ export async function searchFilesForRemote( const toolResults = searchToolsForAutocomplete(query || ""); // File search requires at least 2 chars to avoid loading entire workspace - if (!query || query.length < 2 || query.length > MAX_SEARCH_QUERY_LENGTH) { + if (!query || query.length < 2) { return toolResults; } From d647049f17fff70c7bfe5e1c9716d1548c43e308 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:32:30 +0400 Subject: [PATCH 17/35] fix: format code for better readability in tools and persistence modules --- tasksync-chat/src/tools.ts | 20 ++++++++++---------- tasksync-chat/src/webview/persistence.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index a0510e3..f3f21e0 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -212,16 +212,16 @@ export function registerTools( | vscode.LanguageModelTextPart | vscode.LanguageModelDataPart )[] = [ - new vscode.LanguageModelTextPart( - JSON.stringify({ - response: result.response, - queued: result.queue, - attachmentCount: result.attachments.length, - instruction: - "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", - }), - ), - ]; + new vscode.LanguageModelTextPart( + JSON.stringify({ + response: result.response, + queued: result.queue, + attachmentCount: result.attachments.length, + instruction: + "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", + }), + ), + ]; // Add image attachments as LanguageModelDataPart for vision models if (result.attachments && result.attachments.length > 0) { diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index a7bdf09..3b7e989 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -94,8 +94,8 @@ export async function loadPersistedHistoryFromDiskAsync(p: P): Promise { const parsed = JSON.parse(data); p._persistedHistory = Array.isArray(parsed.history) ? parsed.history - .filter((entry: ToolCallEntry) => entry.status === "completed") - .slice(0, p._MAX_HISTORY_ENTRIES) + .filter((entry: ToolCallEntry) => entry.status === "completed") + .slice(0, p._MAX_HISTORY_ENTRIES) : []; } catch (error) { console.error("[TaskSync] Failed to load persisted history:", error); From 77eb5c61a85d18384d9f1147428df8c185ac435e Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:50:08 +0400 Subject: [PATCH 18/35] fix: remove unnecessary defensive code from Copilot review - Fix triple-duplicated notifyQueueChanged in handleRemoveQueuePrompt (corruption from multi_replace) - Revert safeQuestion wrapper in tools.ts (VS Code LM API validates schema before invoke) - Revert over-engineered hasTerminal type guard in terminalContext.ts (simple 'in' check is sufficient) --- tasksync-chat/src/context/terminalContext.ts | 7 +------ tasksync-chat/src/tools.ts | 10 ++-------- tasksync-chat/src/webview/queueHandlers.ts | 6 ------ 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index 2f0bf61..12795a8 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -124,13 +124,8 @@ export class TerminalContextProvider implements vscode.Disposable { // Remove all execution trackers associated with this terminal const toDelete: vscode.TerminalShellExecution[] = []; - const hasTerminal = ( - exec: vscode.TerminalShellExecution, - ): exec is vscode.TerminalShellExecution & { terminal: vscode.Terminal } => - typeof (exec as unknown as Record).terminal !== "undefined"; - for (const [execution] of this._activeExecutions) { - if (hasTerminal(execution) && execution.terminal === terminal) { + if ("terminal" in execution && execution.terminal === terminal) { toDelete.push(execution); } } diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index f3f21e0..a3a577e 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -193,19 +193,13 @@ export function registerTools( token: vscode.CancellationToken, ) { const params = options.input; - const safeQuestion = - typeof params?.question === "string" ? params.question : ""; debugLog( "[TaskSync] LM tool invoke — question:", - safeQuestion.slice(0, 60), + params.question.slice(0, 60), ); try { - const safeParams: Input = { - question: safeQuestion, - summary: params?.summary, - }; - const result = await askUser(safeParams, provider, token); + const result = await askUser(params, provider, token); // Build result parts - text first, then images const resultParts: ( diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index a20c78f..1146f6c 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -146,12 +146,6 @@ export function handleRemoveQueuePrompt(p: P, promptId: string): void { if (p._promptQueue.length !== beforeLength) { notifyQueueChanged(p); } - if (p._promptQueue.length !== beforeLength) { - notifyQueueChanged(p); - } - if (p._promptQueue.length !== beforeLength) { - notifyQueueChanged(p); - } } /** From 13011fe5312adfd00907b6b677e654971f3bda07 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:51:59 +0400 Subject: [PATCH 19/35] restore: keep defensive coding in tools.ts and terminalContext.ts Defensive coding (safeQuestion, hasTerminal) is good practice and harmless. Only the triple notifyQueueChanged fix from the previous commit remains. --- tasksync-chat/src/context/terminalContext.ts | 7 ++++++- tasksync-chat/src/tools.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index 12795a8..2f0bf61 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -124,8 +124,13 @@ export class TerminalContextProvider implements vscode.Disposable { // Remove all execution trackers associated with this terminal const toDelete: vscode.TerminalShellExecution[] = []; + const hasTerminal = ( + exec: vscode.TerminalShellExecution, + ): exec is vscode.TerminalShellExecution & { terminal: vscode.Terminal } => + typeof (exec as unknown as Record).terminal !== "undefined"; + for (const [execution] of this._activeExecutions) { - if ("terminal" in execution && execution.terminal === terminal) { + if (hasTerminal(execution) && execution.terminal === terminal) { toDelete.push(execution); } } diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index a3a577e..f3f21e0 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -193,13 +193,19 @@ export function registerTools( token: vscode.CancellationToken, ) { const params = options.input; + const safeQuestion = + typeof params?.question === "string" ? params.question : ""; debugLog( "[TaskSync] LM tool invoke — question:", - params.question.slice(0, 60), + safeQuestion.slice(0, 60), ); try { - const result = await askUser(params, provider, token); + const safeParams: Input = { + question: safeQuestion, + summary: params?.summary, + }; + const result = await askUser(safeParams, provider, token); // Build result parts - text first, then images const resultParts: ( From 4072de1408b496368adcf2d367decc0bb08c914f Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 11:59:55 +0400 Subject: [PATCH 20/35] ci: add duplicate-block scanner, CI workflow, pre-push hook, and call-count test - scripts/check-duplicates.js: Detects consecutive duplicate code blocks (tool corruption) - .github/workflows/ci.yml: Runs build/tsc/test/lint/check-duplicates on PRs - .githooks/pre-push: Local pre-push hook (activate with: git config core.hooksPath .githooks) - Added 'check-duplicates' and 'validate' npm scripts - New test: notifyQueueChanged called exactly once per queue removal - Updated AGENTS.md and copilot-instructions.md with validate workflow --- .githooks/pre-push | 26 ++++ .github/copilot-instructions.md | 11 +- .github/workflows/ci.yml | 43 ++++++ AGENTS.md | 6 +- tasksync-chat/package.json | 2 + tasksync-chat/scripts/check-duplicates.js | 131 ++++++++++++++++++ .../src/webview/queueHandlers.test.ts | 10 ++ 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100755 .githooks/pre-push create mode 100644 .github/workflows/ci.yml create mode 100644 tasksync-chat/scripts/check-duplicates.js diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..c9c1569 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,26 @@ +#!/bin/sh +# Pre-push hook: runs build, type-check, tests, lint, and duplicate scanner +# Install: git config core.hooksPath .githooks + +set -e + +echo "🔍 Running pre-push checks..." + +cd tasksync-chat + +echo "📦 Building..." +node esbuild.js > /dev/null 2>&1 + +echo "🔎 Type-checking..." +npx tsc --noEmit + +echo "🧪 Running tests..." +npx vitest run + +echo "🧹 Linting..." +npm run lint + +echo "🔁 Scanning for duplicate blocks..." +node scripts/check-duplicates.js + +echo "✅ All pre-push checks passed!" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dfc12dd..c934ce0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,10 +30,17 @@ All commands run from `tasksync-chat/`: ```bash cd tasksync-chat npm install # Always run first +npm run validate # Full validation: build + tsc + test + lint + duplicate scanner +``` + +Or run individually: + +```bash node esbuild.js # Build → dist/extension.js, media/webview.js, web/shared-constants.js npx tsc --noEmit # Type-check (must produce 0 errors) -npx vitest run # Run all tests (384+ tests, must all pass) +npx vitest run # Run all tests (385+ tests, must all pass) npm run lint # Biome lint (must produce 0 issues) +npm run check-duplicates # Scan for duplicate code blocks (tool corruption detection) ``` Always run these four checks (build, tsc, vitest, lint) after making changes. The CI workflow (`.github/workflows/auto-release.yml`) runs on pushes to `main` — it installs deps, builds, version-bumps, packages VSIX, and creates a GitHub release. @@ -76,7 +83,7 @@ Follow OWASP Top 10 principles. Specific patterns enforced in this codebase: ## Testing Strategy -- **Framework:** Vitest, 14 test files, 384+ tests, ~98% coverage +- **Framework:** Vitest, 14 test files, 385+ tests, ~98% coverage - **VS Code mock:** `src/__mocks__/vscode.ts` — mocks VS Code API for unit tests - **Test setup:** Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` - **Coverage requirement:** Maintain or improve coverage. Add tests for new logic, especially: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86478cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + branches: [main] + paths: + - 'tasksync-chat/**' + - '.github/workflows/ci.yml' + +jobs: + validate: + runs-on: ubuntu-latest + defaults: + run: + working-directory: tasksync-chat + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tasksync-chat/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Type-check + run: npx tsc --noEmit + + - name: Test + run: npm test + + - name: Lint + run: npm run lint + + - name: Check for duplicate blocks + run: npm run check-duplicates diff --git a/AGENTS.md b/AGENTS.md index 5005ff7..076624b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,12 +82,14 @@ npm install | Type-check | `npx tsc --noEmit` | | Test | `npx vitest run` | | Lint | `npm run lint` | +| Duplicate check | `npm run check-duplicates` | +| Full validation | `npm run validate` | | Watch mode | `npm run watch` | | Package VSIX | `npx vsce package` | > **Build output** goes to `dist/` (extension bundle), `media/webview.js` (webview bundle), and `web/shared-constants.js` (auto-generated for the remote PWA). > -> Always run build, tsc, vitest, and lint after making changes. +> Always run `npm run validate` after making changes. This runs build, tsc, vitest, lint, and the duplicate block scanner. --- @@ -135,7 +137,7 @@ These principles are mandatory for all changes: ## Testing -- **Framework:** Vitest (14 test files, 384+ tests, ~98% coverage) +- **Framework:** Vitest (14 test files, 385+ tests, ~98% coverage) - **Mocks:** VS Code API is mocked in `src/__mocks__/vscode.ts` - **Test setup:** Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` - **Coverage:** Maintain or improve coverage. Add tests for security-sensitive code, edge cases, and error handling paths. diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 80e07ce..5ae3f45 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -372,6 +372,8 @@ "compile": "tsc -p ./", "watch": "node esbuild.js --watch", "lint": "biome lint src", + "check-duplicates": "node scripts/check-duplicates.js", + "validate": "npm run build && npx tsc --noEmit && npm test && npm run lint && npm run check-duplicates", "e2e": "playwright test -c e2e/playwright.config.mjs", "e2e:headed": "playwright test -c e2e/playwright.config.mjs --headed", "e2e:ui": "playwright test -c e2e/playwright.config.mjs --ui", diff --git a/tasksync-chat/scripts/check-duplicates.js b/tasksync-chat/scripts/check-duplicates.js new file mode 100644 index 0000000..fded47e --- /dev/null +++ b/tasksync-chat/scripts/check-duplicates.js @@ -0,0 +1,131 @@ +/** + * Scans TypeScript source files for consecutive duplicate code blocks. + * + * Catches corruption from multi-line replacement tools (e.g. the triple + * notifyQueueChanged bug) by detecting 3+ identical consecutive lines + * in the same file. + * + * Usage: node scripts/check-duplicates.js + * Exit code 0 = clean, 1 = duplicates found + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const SRC_DIR = path.join(__dirname, "..", "src"); +const MIN_CONSECUTIVE = 3; // Flag when 3+ identical lines repeat consecutively +const EXTENSIONS = [".ts", ".js"]; +const IGNORE_PATTERNS = [ + /^\s*$/, // Empty lines + /^\s*\/\//, // Single-line comments + /^\s*\*/, // JSDoc / block comment lines + /^\s*[{}]\s*$/, // Lone braces + /^\s*import\s/, // Import lines +]; + +function shouldIgnoreLine(line) { + return IGNORE_PATTERNS.some((pattern) => pattern.test(line)); +} + +function scanFile(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + const lines = content.split("\n"); + const issues = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (shouldIgnoreLine(line)) { + i++; + continue; + } + + // Count consecutive identical lines + let count = 1; + while (i + count < lines.length && lines[i + count] === line) { + count++; + } + + if (count >= MIN_CONSECUTIVE) { + issues.push({ + line: i + 1, + count, + text: line.trim().slice(0, 80), + }); + } + + // Also check for repeated multi-line blocks (2-5 line patterns) + for (let blockSize = 2; blockSize <= 5; blockSize++) { + if (i + blockSize * 2 > lines.length) break; + + const block = lines.slice(i, i + blockSize).join("\n"); + // Skip if block is all ignorable + if (lines.slice(i, i + blockSize).every(shouldIgnoreLine)) break; + + let blockCount = 1; + let offset = blockSize; + while (i + offset + blockSize <= lines.length) { + const nextBlock = lines.slice(i + offset, i + offset + blockSize).join("\n"); + if (nextBlock === block) { + blockCount++; + offset += blockSize; + } else { + break; + } + } + + if (blockCount >= MIN_CONSECUTIVE) { + issues.push({ + line: i + 1, + count: blockCount, + text: `[${blockSize}-line block] ${lines[i].trim().slice(0, 60)}...`, + }); + } + } + + i++; + } + + return issues; +} + +function walkDir(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "__mocks__") continue; + results.push(...walkDir(fullPath)); + } else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + results.push(fullPath); + } + } + return results; +} + +// ── Main ── + +const files = walkDir(SRC_DIR); +let hasIssues = false; + +for (const file of files) { + const issues = scanFile(file); + if (issues.length > 0) { + hasIssues = true; + const relPath = path.relative(path.join(__dirname, ".."), file); + for (const issue of issues) { + console.error( + `❌ ${relPath}:${issue.line} — ${issue.count}x consecutive duplicate: ${issue.text}`, + ); + } + } +} + +if (hasIssues) { + console.error("\n❌ Duplicate blocks detected! This may indicate tool corruption."); + process.exit(1); +} else { + console.log("✅ No duplicate blocks found."); + process.exit(0); +} diff --git a/tasksync-chat/src/webview/queueHandlers.test.ts b/tasksync-chat/src/webview/queueHandlers.test.ts index 8b200d3..747eea3 100644 --- a/tasksync-chat/src/webview/queueHandlers.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.test.ts @@ -36,6 +36,16 @@ describe("handleRemoveQueuePrompt", () => { expect(p._promptQueue.map((q: any) => q.id)).toEqual([ID1, ID3]); }); + it("calls notifyQueueChanged exactly once per removal", () => { + const p = createMockP([ + { id: ID1, prompt: "First" }, + { id: ID2, prompt: "Second" }, + ]); + handleRemoveQueuePrompt(p, ID1); + expect(p._saveQueueToDisk).toHaveBeenCalledTimes(1); + expect(p._updateQueueUI).toHaveBeenCalledTimes(1); + }); + it("does nothing for non-existent ID", () => { const p = createMockP([{ id: ID1, prompt: "First" }]); handleRemoveQueuePrompt(p, "q_999_zzz"); From e44d88b1958801dee137835557a7f8fcb3acfd3d Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 12:00:54 +0400 Subject: [PATCH 21/35] ci: auto-activate git hooks on npm install Add 'prepare' script to set core.hooksPath to .githooks automatically. --- tasksync-chat/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 5ae3f45..71ae353 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -374,6 +374,7 @@ "lint": "biome lint src", "check-duplicates": "node scripts/check-duplicates.js", "validate": "npm run build && npx tsc --noEmit && npm test && npm run lint && npm run check-duplicates", + "prepare": "cd .. && git config core.hooksPath .githooks", "e2e": "playwright test -c e2e/playwright.config.mjs", "e2e:headed": "playwright test -c e2e/playwright.config.mjs --headed", "e2e:ui": "playwright test -c e2e/playwright.config.mjs --ui", From 92b702e539764b874fc6a368b8a5439c58bcb80e Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 12:08:43 +0400 Subject: [PATCH 22/35] fix: address Round 6 Copilot review comments - Sanitize summary param in tools.ts (match safeQuestion pattern) - Fix IPv6 host:port stripping in cert SAN generation (serverUtils.ts) - Add X-XSS-Protection: 0 header to match documented security policy - Validate queue ID in handleAddQueuePrompt via isValidQueueId() - Add test for fully-expanded IPv6 cert generation (386 tests) --- tasksync-chat/src/server/serverUtils.test.ts | 6 ++++++ tasksync-chat/src/server/serverUtils.ts | 13 +++++++++---- tasksync-chat/src/tools.ts | 5 ++++- tasksync-chat/src/webview/queueHandlers.ts | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tasksync-chat/src/server/serverUtils.test.ts b/tasksync-chat/src/server/serverUtils.test.ts index bc8f979..76174d2 100644 --- a/tasksync-chat/src/server/serverUtils.test.ts +++ b/tasksync-chat/src/server/serverUtils.test.ts @@ -65,6 +65,7 @@ describe("setSecurityHeaders", () => { setSecurityHeaders(res); expect(res.headers["X-Content-Type-Options"]).toBe("nosniff"); expect(res.headers["X-Frame-Options"]).toBe("DENY"); + expect(res.headers["X-XSS-Protection"]).toBe("0"); expect(res.headers["Referrer-Policy"]).toBe("no-referrer"); }); @@ -433,4 +434,9 @@ describe("generateSelfSignedCert", () => { const result = await generateSelfSignedCert("myhost.local"); expect(result.cert).toContain("CERTIFICATE"); }); + + it("generates valid cert for fully-expanded IPv6 (no brackets)", async () => { + const result = await generateSelfSignedCert("2001:db8:0:0:0:0:0:1"); + expect(result.cert).toContain("CERTIFICATE"); + }); }); diff --git a/tasksync-chat/src/server/serverUtils.ts b/tasksync-chat/src/server/serverUtils.ts index bb2cc02..16e05ee 100644 --- a/tasksync-chat/src/server/serverUtils.ts +++ b/tasksync-chat/src/server/serverUtils.ts @@ -57,6 +57,7 @@ export function setSecurityHeaders( ): void { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-XSS-Protection", "0"); res.setHeader("Referrer-Policy", "no-referrer"); if (isTls) { res.setHeader("Strict-Transport-Security", "max-age=31536000"); @@ -177,11 +178,15 @@ export async function generateSelfSignedCert(host: string): Promise { // Bracketed IPv6: [::1]:3580 → ::1 const closeBracket = bareHost.indexOf("]"); if (closeBracket !== -1) bareHost = bareHost.slice(1, closeBracket); - } else if (bareHost.includes(":") && !bareHost.includes("::")) { - // host:port (but not IPv6 like ::1) + } else { + // Unbracketed host: only treat as host:port if there is exactly one colon. + // Fully-expanded IPv6 without brackets (e.g. 2001:db8:0:0:0:0:0:1) has multiple colons. + const firstColon = bareHost.indexOf(":"); const lastColon = bareHost.lastIndexOf(":"); - const maybePart = bareHost.slice(lastColon + 1); - if (/^\d+$/.test(maybePart)) bareHost = bareHost.slice(0, lastColon); + if (firstColon !== -1 && firstColon === lastColon) { + const maybePart = bareHost.slice(lastColon + 1); + if (/^\d+$/.test(maybePart)) bareHost = bareHost.slice(0, lastColon); + } } const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(bareHost); const isIPv6 = bareHost.includes(":"); diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index f3f21e0..10ece56 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -203,7 +203,10 @@ export function registerTools( try { const safeParams: Input = { question: safeQuestion, - summary: params?.summary, + summary: + typeof params?.summary === "string" + ? params.summary + : undefined, }; const result = await askUser(safeParams, provider, token); diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index 1146f6c..05d70b3 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -40,7 +40,7 @@ export function handleAddQueuePrompt( ); const queuedPrompt: QueuedPrompt = { - id: id || generateId("q"), + id: isValidQueueId(id) ? id : generateId("q"), prompt: trimmed, attachments: attachments.length > 0 ? [...attachments] : undefined, }; From ffa13af2627832dce2d2a140dbd2695e22b6902d Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 12:18:55 +0400 Subject: [PATCH 23/35] fix: address Round 7 Copilot review comments - Enforce MAX_QUEUE_SIZE in handleAddQueuePrompt (reject when full) - Replace hard-coded 100001 with MAX_QUEUE_PROMPT_LENGTH + 1 in tests (SSOT) - Add MAX_QUEUE_SIZE enforcement test (387 total tests passing) --- .../src/webview/queueHandlers.comprehensive.test.ts | 13 ++++++++++++- tasksync-chat/src/webview/queueHandlers.test.ts | 3 ++- tasksync-chat/src/webview/queueHandlers.ts | 7 +++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index 4f00a21..b8de63d 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { MAX_QUEUE_PROMPT_LENGTH, MAX_QUEUE_SIZE } from "../constants/remoteConstants"; import * as vscode from "vscode"; import { handleAddQueuePrompt, @@ -74,7 +75,7 @@ describe("handleAddQueuePrompt", () => { it("rejects overly long prompt", () => { const p = createMockP(); - const longPrompt = "x".repeat(100001); + const longPrompt = "x".repeat(MAX_QUEUE_PROMPT_LENGTH + 1); handleAddQueuePrompt(p, longPrompt, "q_1_abc", []); expect(p._promptQueue).toHaveLength(0); }); @@ -99,6 +100,16 @@ describe("handleAddQueuePrompt", () => { expect(p._updateAttachmentsUI).toHaveBeenCalled(); }); + it("rejects when queue is full", () => { + const queue = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({ + id: `q_${i}_abc`, + prompt: `task ${i}`, + })); + const p = createMockP({ _promptQueue: queue }); + handleAddQueuePrompt(p, "overflow", "q_999_abc", []); + expect(p._promptQueue).toHaveLength(MAX_QUEUE_SIZE); + }); + // Auto-respond path it("auto-responds when queue is enabled and tool call is pending", () => { const resolve = vi.fn(); diff --git a/tasksync-chat/src/webview/queueHandlers.test.ts b/tasksync-chat/src/webview/queueHandlers.test.ts index 747eea3..964e653 100644 --- a/tasksync-chat/src/webview/queueHandlers.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { MAX_QUEUE_PROMPT_LENGTH } from "../constants/remoteConstants"; import { handleClearQueue, handleEditQueuePrompt, @@ -84,7 +85,7 @@ describe("handleEditQueuePrompt", () => { it("rejects excessively long prompts", () => { const p = createMockP([{ id: ID1, prompt: "Keep" }]); - const longPrompt = "x".repeat(100001); + const longPrompt = "x".repeat(MAX_QUEUE_PROMPT_LENGTH + 1); handleEditQueuePrompt(p, ID1, longPrompt); expect(p._promptQueue[0].prompt).toBe("Keep"); expect(p._saveQueueToDisk).not.toHaveBeenCalled(); diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index 05d70b3..e8e5f5e 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -1,4 +1,5 @@ import { + MAX_QUEUE_SIZE, isValidQueueId, MAX_QUEUE_PROMPT_LENGTH, } from "../constants/remoteConstants"; @@ -119,6 +120,12 @@ export function handleAddQueuePrompt( } if (!handledAsToolResponse) { + if (p._promptQueue.length >= MAX_QUEUE_SIZE) { + debugLog( + `[TaskSync] handleAddQueuePrompt — rejected: queue full (${p._promptQueue.length}/${MAX_QUEUE_SIZE})`, + ); + return; + } debugLog( `[TaskSync] handleAddQueuePrompt — no pending tool call, adding to queue (new size: ${p._promptQueue.length + 1})`, ); From 1f043ea79918e92d8b6a862d6421265232969a66 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 12:20:52 +0400 Subject: [PATCH 24/35] docs: clarify reconnection constants are build-script defaults in esbuild.js --- tasksync-chat/esbuild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasksync-chat/esbuild.js b/tasksync-chat/esbuild.js index 906d2ce..1562d79 100644 --- a/tasksync-chat/esbuild.js +++ b/tasksync-chat/esbuild.js @@ -73,7 +73,7 @@ function getTaskSyncWsProtocol() { return location.protocol === 'https:' ? 'wss:' : 'ws:'; } -// Reconnection settings +// Reconnection settings (build-script defaults, not from remoteConstants.ts — PWA-only) var TASKSYNC_MAX_RECONNECT_ATTEMPTS = 20; var TASKSYNC_MAX_RECONNECT_DELAY_MS = 30000; From e898563c00f36d56dc8b48fd2d72708970879010 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 12:23:57 +0400 Subject: [PATCH 25/35] docs: update .github/instructions and documentation with Round 7 changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update security.instructions.md: workspace-root validation, backslash normalization, X-XSS-Protection: 0 - Update testing.instructions.md: 387+ tests, SSOT pattern for test constants - Update webview.instructions.md: MAX_QUEUE_SIZE/MAX_QUEUE_PROMPT_LENGTH limits, async mkdir - Update copilot-instructions.md and AGENTS.md: test count 385+ → 387+ --- .github/copilot-instructions.md | 4 +- .github/instructions/security.instructions.md | 5 +- .github/instructions/testing.instructions.md | 3 +- .github/instructions/webview.instructions.md | 2 + AGENTS.md | 2 +- tasksync-chat/e2e/playwright.config.mjs | 46 +++++------ tasksync-chat/e2e/tests/remote-auth.spec.mjs | 77 ++++++++++--------- tasksync-chat/src/context/terminalContext.ts | 7 +- tasksync-chat/src/server/remoteGitHandlers.ts | 2 +- tasksync-chat/src/tools.ts | 24 +++--- .../src/webview/lifecycleHandlers.ts | 5 +- tasksync-chat/src/webview/persistence.ts | 4 +- .../queueHandlers.comprehensive.test.ts | 5 +- tasksync-chat/src/webview/queueHandlers.ts | 2 +- .../src/webview/remoteApiHandlers.ts | 22 +++--- tasksync-chat/web/shared-constants.js | 2 +- 16 files changed, 116 insertions(+), 96 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c934ce0..8faec3c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,7 +38,7 @@ Or run individually: ```bash node esbuild.js # Build → dist/extension.js, media/webview.js, web/shared-constants.js npx tsc --noEmit # Type-check (must produce 0 errors) -npx vitest run # Run all tests (385+ tests, must all pass) +npx vitest run # Run all tests (387+ tests, must all pass) npm run lint # Biome lint (must produce 0 issues) npm run check-duplicates # Scan for duplicate code blocks (tool corruption detection) ``` @@ -83,7 +83,7 @@ Follow OWASP Top 10 principles. Specific patterns enforced in this codebase: ## Testing Strategy -- **Framework:** Vitest, 14 test files, 385+ tests, ~98% coverage +- **Framework:** Vitest, 14 test files, 387+ tests, ~98% coverage - **VS Code mock:** `src/__mocks__/vscode.ts` — mocks VS Code API for unit tests - **Test setup:** Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` - **Coverage requirement:** Maintain or improve coverage. Add tests for new logic, especially: diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md index 9673f99..ad25f14 100644 --- a/.github/instructions/security.instructions.md +++ b/.github/instructions/security.instructions.md @@ -14,6 +14,8 @@ All code in `src/server/` handles remote client connections and must follow stri ## Path Traversal Prevention - All file paths from remote clients must be validated with `isValidFilePath()` in `gitService.ts` - Reject `..`, null bytes, backticks, and shell metacharacters +- Absolute paths are only allowed when they resolve under the workspace root +- Normalize backslashes to forward slashes before validation (Windows compatibility) - Use `path.isAbsolute()` instead of `startsWith("/")` for cross-platform correctness - Use the shared `resolveFilePath()` helper for getDiff/stage/unstage/discard operations @@ -34,6 +36,7 @@ All code in `src/server/` handles remote client connections and must follow stri - Always test IPv4, IPv6 (`::1`), bracketed IPv6 (`[::1]:port`), and hostname:port formats ## HTTP Security -- Set `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection` on all responses +- Set `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection: 0` on all responses +- Use `X-XSS-Protection: 0` (not `1; mode=block`) \u2014 the built-in XSS auditor is deprecated and can introduce vulnerabilities; CSP is the proper mitigation - Check `Origin` and `Host` headers on WebSocket upgrade requests - See `serverUtils.ts` `setSecurityHeaders()` and `isAllowedOrigin()` diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 88b95c1..74ef217 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -5,7 +5,7 @@ applyTo: "**/*.test.ts" # Testing Standards ## Framework -- Vitest with 384+ tests across 14 test files (~98% coverage) +- Vitest with 387+ tests across 14 test files (~98% coverage) - VS Code API is mocked in `src/__mocks__/vscode.ts` ## Test Setup @@ -23,3 +23,4 @@ applyTo: "**/*.test.ts" - Use descriptive test names: "throws for invalid file path", "strips port from hostname" - Test both happy paths and error conditions - For async operations, use `await expect(...).rejects.toThrow()` +- Import constants from `remoteConstants.ts` in tests — do not hard-code values (SSOT) diff --git a/.github/instructions/webview.instructions.md b/.github/instructions/webview.instructions.md index ee86e5d..bfdaa12 100644 --- a/.github/instructions/webview.instructions.md +++ b/.github/instructions/webview.instructions.md @@ -23,3 +23,5 @@ applyTo: "tasksync-chat/src/webview/**" ## Storage - Queue, history, and settings are per-workspace (workspace-scoped storage with global fallback) +- Queue enforces `MAX_QUEUE_SIZE` (100) and `MAX_QUEUE_PROMPT_LENGTH` (100000) limits +- Use `await fs.promises.mkdir(path, { recursive: true })` for directory creation \u2014 never `fs.existsSync` diff --git a/AGENTS.md b/AGENTS.md index 076624b..4c76cbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,7 @@ These principles are mandatory for all changes: ## Testing -- **Framework:** Vitest (14 test files, 385+ tests, ~98% coverage) +- **Framework:** Vitest (14 test files, 387+ tests, ~98% coverage) - **Mocks:** VS Code API is mocked in `src/__mocks__/vscode.ts` - **Test setup:** Tests that use git operations must set `(vscode.workspace as any).workspaceFolders` in `beforeEach` - **Coverage:** Maintain or improve coverage. Add tests for security-sensitive code, edge cases, and error handling paths. diff --git a/tasksync-chat/e2e/playwright.config.mjs b/tasksync-chat/e2e/playwright.config.mjs index 8fafeba..acd13a9 100644 --- a/tasksync-chat/e2e/playwright.config.mjs +++ b/tasksync-chat/e2e/playwright.config.mjs @@ -1,27 +1,27 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; -const baseURL = process.env.TASKSYNC_E2E_BASE_URL || 'http://127.0.0.1:3580'; +const baseURL = process.env.TASKSYNC_E2E_BASE_URL || "http://127.0.0.1:3580"; export default defineConfig({ - testDir: './tests', - timeout: 60000, - expect: { timeout: 10000 }, - fullyParallel: false, - retries: process.env.CI ? 2 : 0, - reporter: [ - ['list'], - ['html', { open: 'never', outputFolder: 'playwright-report' }], - ], - use: { - baseURL, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], + testDir: "./tests", + timeout: 60000, + expect: { timeout: 10000 }, + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + reporter: [ + ["list"], + ["html", { open: "never", outputFolder: "playwright-report" }], + ], + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], }); diff --git a/tasksync-chat/e2e/tests/remote-auth.spec.mjs b/tasksync-chat/e2e/tests/remote-auth.spec.mjs index e3db857..0b4dc15 100644 --- a/tasksync-chat/e2e/tests/remote-auth.spec.mjs +++ b/tasksync-chat/e2e/tests/remote-auth.spec.mjs @@ -1,52 +1,59 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; const expectPinMode = process.env.TASKSYNC_E2E_EXPECT_PIN; -const e2ePin = process.env.TASKSYNC_E2E_PIN || ''; +const e2ePin = process.env.TASKSYNC_E2E_PIN || ""; function parseExpectPinMode() { - if (expectPinMode === 'true') return true; - if (expectPinMode === 'false') return false; - return null; + if (expectPinMode === "true") return true; + if (expectPinMode === "false") return false; + return null; } async function fillPin(page, pin) { - const digits = pin.slice(0, 6).split(''); - for (let i = 0; i < digits.length; i++) { - await page.locator('.pin-digit').nth(i).fill(digits[i]); - } + const digits = pin.slice(0, 6).split(""); + for (let i = 0; i < digits.length; i++) { + await page.locator(".pin-digit").nth(i).fill(digits[i]); + } } -test('remote login page renders expected controls', async ({ page }) => { - await page.goto('/'); - await expect(page).toHaveTitle(/TaskSync Remote/i); - await expect(page.locator('.pin-digit').first()).toBeVisible(); - await expect(page.locator('#submit')).toBeVisible(); +test("remote login page renders expected controls", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/TaskSync Remote/i); + await expect(page.locator(".pin-digit").first()).toBeVisible(); + await expect(page.locator("#submit")).toBeVisible(); }); -test('api auth behavior matches pin-mode expectation', async ({ request }) => { - const response = await request.get('/api/files?query=readme'); - const mode = parseExpectPinMode(); +test("api auth behavior matches pin-mode expectation", async ({ request }) => { + const response = await request.get("/api/files?query=readme"); + const mode = parseExpectPinMode(); - if (mode === true) { - expect([401, 429]).toContain(response.status()); - return; - } + if (mode === true) { + expect([401, 429]).toContain(response.status()); + return; + } - if (mode === false) { - expect(response.status()).toBe(200); - return; - } + if (mode === false) { + expect(response.status()).toBe(200); + return; + } - expect([200, 401, 429]).toContain(response.status()); + expect([200, 401, 429]).toContain(response.status()); }); -test('pin login succeeds when TASKSYNC_E2E_PIN is provided', async ({ page }) => { - test.skip(!/^\d{4,6}$/.test(e2ePin), 'Set TASKSYNC_E2E_PIN=4..6 digit PIN to run this test'); - - await page.goto('/'); - await fillPin(page, e2ePin); - await page.locator('#submit').click(); - - await expect(page).toHaveURL(/\/app\.html$/i, { timeout: 15000 }); - await expect(page.locator('.remote-header-title')).toHaveText(/TaskSync/i, { timeout: 15000 }); +test("pin login succeeds when TASKSYNC_E2E_PIN is provided", async ({ + page, +}) => { + test.skip( + !/^\d{4,6}$/.test(e2ePin), + "Set TASKSYNC_E2E_PIN=4..6 digit PIN to run this test", + ); + + await page.goto("/"); + await fillPin(page, e2ePin); + await page.locator("#submit").click(); + + await expect(page).toHaveURL(/\/app\.html$/i, { timeout: 15000 }); + await expect(page.locator(".remote-header-title")).toHaveText(/TaskSync/i, { + timeout: 15000, + }); }); diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index 2f0bf61..8ee2358 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -126,8 +126,11 @@ export class TerminalContextProvider implements vscode.Disposable { const hasTerminal = ( exec: vscode.TerminalShellExecution, - ): exec is vscode.TerminalShellExecution & { terminal: vscode.Terminal } => - typeof (exec as unknown as Record).terminal !== "undefined"; + ): exec is vscode.TerminalShellExecution & { + terminal: vscode.Terminal; + } => + typeof (exec as unknown as Record).terminal !== + "undefined"; for (const [execution] of this._activeExecutions) { if (hasTerminal(execution) && execution.terminal === terminal) { diff --git a/tasksync-chat/src/server/remoteGitHandlers.ts b/tasksync-chat/src/server/remoteGitHandlers.ts index d0802aa..26b92a4 100644 --- a/tasksync-chat/src/server/remoteGitHandlers.ts +++ b/tasksync-chat/src/server/remoteGitHandlers.ts @@ -265,7 +265,7 @@ export async function dispatchGitMessage( gitServiceAvailable: boolean, broadcast: BroadcastFn, searchFn: (query: string) => Promise, - msg: { type: string;[key: string]: unknown }, + msg: { type: string; [key: string]: unknown }, ): Promise { switch (msg.type) { case "getChanges": diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index 10ece56..fdb4516 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -204,9 +204,7 @@ export function registerTools( const safeParams: Input = { question: safeQuestion, summary: - typeof params?.summary === "string" - ? params.summary - : undefined, + typeof params?.summary === "string" ? params.summary : undefined, }; const result = await askUser(safeParams, provider, token); @@ -215,16 +213,16 @@ export function registerTools( | vscode.LanguageModelTextPart | vscode.LanguageModelDataPart )[] = [ - new vscode.LanguageModelTextPart( - JSON.stringify({ - response: result.response, - queued: result.queue, - attachmentCount: result.attachments.length, - instruction: - "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", - }), - ), - ]; + new vscode.LanguageModelTextPart( + JSON.stringify({ + response: result.response, + queued: result.queue, + attachmentCount: result.attachments.length, + instruction: + "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", + }), + ), + ]; // Add image attachments as LanguageModelDataPart for vision models if (result.attachments && result.attachments.length > 0) { diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index aa4ea47..adeb379 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -80,7 +80,10 @@ export function getHtmlContent( try { cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); } catch (err) { - console.error("Failed to load webview body template (sync fallback):", err); + console.error( + "Failed to load webview body template (sync fallback):", + err, + ); } } let bodyHtml = diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index 3b7e989..a7bdf09 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -94,8 +94,8 @@ export async function loadPersistedHistoryFromDiskAsync(p: P): Promise { const parsed = JSON.parse(data); p._persistedHistory = Array.isArray(parsed.history) ? parsed.history - .filter((entry: ToolCallEntry) => entry.status === "completed") - .slice(0, p._MAX_HISTORY_ENTRIES) + .filter((entry: ToolCallEntry) => entry.status === "completed") + .slice(0, p._MAX_HISTORY_ENTRIES) : []; } catch (error) { console.error("[TaskSync] Failed to load persisted history:", error); diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index b8de63d..590470e 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { MAX_QUEUE_PROMPT_LENGTH, MAX_QUEUE_SIZE } from "../constants/remoteConstants"; import * as vscode from "vscode"; +import { + MAX_QUEUE_PROMPT_LENGTH, + MAX_QUEUE_SIZE, +} from "../constants/remoteConstants"; import { handleAddQueuePrompt, handleClearPersistedHistory, diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index e8e5f5e..53a9b8e 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -1,7 +1,7 @@ import { - MAX_QUEUE_SIZE, isValidQueueId, MAX_QUEUE_PROMPT_LENGTH, + MAX_QUEUE_SIZE, } from "../constants/remoteConstants"; import { buildSettingsPayload } from "./settingsHandlers"; import type { diff --git a/tasksync-chat/src/webview/remoteApiHandlers.ts b/tasksync-chat/src/webview/remoteApiHandlers.ts index 0ced16f..03f1fed 100644 --- a/tasksync-chat/src/webview/remoteApiHandlers.ts +++ b/tasksync-chat/src/webview/remoteApiHandlers.ts @@ -77,17 +77,17 @@ export function getRemoteState(p: P): { pending: pendingEntry && pendingEntry.status === "pending" ? { - id: pendingEntry.id, - prompt: pendingEntry.prompt, - summary: pendingEntry.summary, - choices: parseChoices(pendingEntry.prompt).map((c) => ({ - label: c.label, - value: c.value, - shortLabel: c.shortLabel, - })), - isApproval: isApprovalQuestion(pendingEntry.prompt), - timestamp: pendingEntry.timestamp, - } + id: pendingEntry.id, + prompt: pendingEntry.prompt, + summary: pendingEntry.summary, + choices: parseChoices(pendingEntry.prompt).map((c) => ({ + label: c.label, + value: c.value, + shortLabel: c.shortLabel, + })), + isApproval: isApprovalQuestion(pendingEntry.prompt), + timestamp: pendingEntry.timestamp, + } : null, queue: p._promptQueue.map( (q: { id: string; prompt: string; attachments?: AttachmentInfo[] }) => ({ diff --git a/tasksync-chat/web/shared-constants.js b/tasksync-chat/web/shared-constants.js index c097b76..5fdfca7 100644 --- a/tasksync-chat/web/shared-constants.js +++ b/tasksync-chat/web/shared-constants.js @@ -20,7 +20,7 @@ function getTaskSyncWsProtocol() { return location.protocol === 'https:' ? 'wss:' : 'ws:'; } -// Reconnection settings +// Reconnection settings (build-script defaults, not from remoteConstants.ts — PWA-only) var TASKSYNC_MAX_RECONNECT_ATTEMPTS = 20; var TASKSYNC_MAX_RECONNECT_DELAY_MS = 30000; From f2045bc4859cbd135dbd546530bcecfc293e8624 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 13:06:28 +0400 Subject: [PATCH 26/35] fix: async I/O, unified scanner, MCP hardening, CSP tightening - Convert sync I/O to async in webviewUtils, fileHandlers, tools.ts - Convert serveFile to async/await in remoteHtmlService - MCP config merge preserves existing keys - MCP session reaper uses sequential close - Tighten CSP WebSocket fallback to empty string - Convert cleanupTempImagesByUri to async - Consolidate check-duplicates + check-sync-io into unified check-code-quality.js with plugin-style CHECKERS registry - Update package.json, pre-push hook, CI workflow, docs --- .githooks/pre-push | 4 +- .github/copilot-instructions.md | 4 +- .github/workflows/ci.yml | 4 +- AGENTS.md | 4 +- tasksync-chat/package.json | 5 +- tasksync-chat/scripts/check-code-quality.js | 215 ++++++++++++++++++ tasksync-chat/scripts/check-duplicates.js | 131 ----------- tasksync-chat/src/mcp/mcpServer.ts | 45 ++-- tasksync-chat/src/server/remoteHtmlService.ts | 115 +++++----- tasksync-chat/src/tools.ts | 24 +- tasksync-chat/src/webview/fileHandlers.ts | 26 ++- .../src/webview/lifecycleHandlers.ts | 2 +- tasksync-chat/src/webview/persistence.ts | 12 +- .../queueHandlers.comprehensive.test.ts | 2 +- .../src/webview/webviewUtils.test.ts | 73 +++--- tasksync-chat/src/webview/webviewUtils.ts | 26 ++- 16 files changed, 402 insertions(+), 290 deletions(-) create mode 100644 tasksync-chat/scripts/check-code-quality.js delete mode 100644 tasksync-chat/scripts/check-duplicates.js diff --git a/.githooks/pre-push b/.githooks/pre-push index c9c1569..b64535b 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -20,7 +20,7 @@ npx vitest run echo "🧹 Linting..." npm run lint -echo "🔁 Scanning for duplicate blocks..." -node scripts/check-duplicates.js +echo "🔁 Running code quality checks..." +node scripts/check-code-quality.js echo "✅ All pre-push checks passed!" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8faec3c..977de2d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,7 +30,7 @@ All commands run from `tasksync-chat/`: ```bash cd tasksync-chat npm install # Always run first -npm run validate # Full validation: build + tsc + test + lint + duplicate scanner +npm run validate # Full validation: build + tsc + test + lint + code quality scanner ``` Or run individually: @@ -40,7 +40,7 @@ node esbuild.js # Build → dist/extension.js, media/webview.js, web/shared npx tsc --noEmit # Type-check (must produce 0 errors) npx vitest run # Run all tests (387+ tests, must all pass) npm run lint # Biome lint (must produce 0 issues) -npm run check-duplicates # Scan for duplicate code blocks (tool corruption detection) +npm run check-code # Code quality scanner (duplicates, sync I/O, etc.) ``` Always run these four checks (build, tsc, vitest, lint) after making changes. The CI workflow (`.github/workflows/auto-release.yml`) runs on pushes to `main` — it installs deps, builds, version-bumps, packages VSIX, and creates a GitHub release. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86478cc..19ce536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,5 +39,5 @@ jobs: - name: Lint run: npm run lint - - name: Check for duplicate blocks - run: npm run check-duplicates + - name: Code quality checks + run: npm run check-code diff --git a/AGENTS.md b/AGENTS.md index 4c76cbd..e57401f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,14 +82,14 @@ npm install | Type-check | `npx tsc --noEmit` | | Test | `npx vitest run` | | Lint | `npm run lint` | -| Duplicate check | `npm run check-duplicates` | +| Code quality | `npm run check-code` | | Full validation | `npm run validate` | | Watch mode | `npm run watch` | | Package VSIX | `npx vsce package` | > **Build output** goes to `dist/` (extension bundle), `media/webview.js` (webview bundle), and `web/shared-constants.js` (auto-generated for the remote PWA). > -> Always run `npm run validate` after making changes. This runs build, tsc, vitest, lint, and the duplicate block scanner. +> Always run `npm run validate` after making changes. This runs build, tsc, vitest, lint, and the code quality scanner. --- diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index 71ae353..825a01e 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -372,8 +372,9 @@ "compile": "tsc -p ./", "watch": "node esbuild.js --watch", "lint": "biome lint src", - "check-duplicates": "node scripts/check-duplicates.js", - "validate": "npm run build && npx tsc --noEmit && npm test && npm run lint && npm run check-duplicates", + "check-duplicates": "node scripts/check-code-quality.js", + "check-code": "node scripts/check-code-quality.js", + "validate": "npm run build && npx tsc --noEmit && npm test && npm run lint && npm run check-code", "prepare": "cd .. && git config core.hooksPath .githooks", "e2e": "playwright test -c e2e/playwright.config.mjs", "e2e:headed": "playwright test -c e2e/playwright.config.mjs --headed", diff --git a/tasksync-chat/scripts/check-code-quality.js b/tasksync-chat/scripts/check-code-quality.js new file mode 100644 index 0000000..b694c38 --- /dev/null +++ b/tasksync-chat/scripts/check-code-quality.js @@ -0,0 +1,215 @@ +/** + * Unified code quality scanner for TaskSync. + * + * Runs all custom static-analysis checks in a single pass over the source tree. + * Adding a new check = adding a new checker object to CHECKERS below. + * No package.json changes needed — `npm run check-code` runs everything. + * + * Current checks: + * 1. Duplicate blocks — catches tool corruption (3+ identical consecutive lines/blocks) + * 2. Sync I/O — catches blocking fs calls (must use fs.promises.*) + * + * Usage: node scripts/check-code-quality.js + * Exit code 0 = clean, 1 = violations found + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const SRC_DIR = path.join(__dirname, "..", "src"); +const IGNORE_DIRS = ["node_modules", "__mocks__"]; +const FILE_EXTENSIONS = [".ts", ".js"]; + +// ── Shared utilities ── + +function walkDir(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (IGNORE_DIRS.includes(entry.name)) continue; + results.push(...walkDir(fullPath)); + } else if (FILE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + results.push(fullPath); + } + } + return results; +} + +function relPath(filePath) { + return path.relative(path.join(__dirname, ".."), filePath); +} + +// ── Check 1: Duplicate blocks ── + +const DUPLICATE_IGNORE_PATTERNS = [ + /^\s*$/, // Empty lines + /^\s*\/\//, // Single-line comments + /^\s*\*/, // JSDoc / block comment lines + /^\s*[{}]\s*$/, // Lone braces + /^\s*import\s/, // Import lines +]; + +function shouldIgnoreDupLine(line) { + return DUPLICATE_IGNORE_PATTERNS.some((p) => p.test(line)); +} + +const MIN_CONSECUTIVE = 3; + +function checkDuplicates(filePath, content) { + const lines = content.split("\n"); + const issues = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (shouldIgnoreDupLine(line)) { + i++; + continue; + } + + // Count consecutive identical lines + let count = 1; + while (i + count < lines.length && lines[i + count] === line) { + count++; + } + + if (count >= MIN_CONSECUTIVE) { + issues.push({ + line: i + 1, + text: `${count}x consecutive duplicate: ${line.trim().slice(0, 80)}`, + }); + } + + // Check for repeated multi-line blocks (2-5 line patterns) + for (let blockSize = 2; blockSize <= 5; blockSize++) { + if (i + blockSize * 2 > lines.length) break; + + const block = lines.slice(i, i + blockSize).join("\n"); + if (lines.slice(i, i + blockSize).every(shouldIgnoreDupLine)) break; + + let blockCount = 1; + let offset = blockSize; + while (i + offset + blockSize <= lines.length) { + const nextBlock = lines + .slice(i + offset, i + offset + blockSize) + .join("\n"); + if (nextBlock === block) { + blockCount++; + offset += blockSize; + } else { + break; + } + } + + if (blockCount >= MIN_CONSECUTIVE) { + issues.push({ + line: i + 1, + text: `${blockCount}x repeated [${blockSize}-line block]: ${lines[i].trim().slice(0, 60)}...`, + }); + } + } + + i++; + } + + return issues; +} + +// ── Check 2: Sync I/O ── + +const SYNC_IO_PATTERNS = [ + /\bfs\.existsSync\b/, + /\bfs\.statSync\b/, + /\bfs\.lstatSync\b/, + /\bfs\.readFileSync\b/, + /\bfs\.writeFileSync\b/, + /\bfs\.mkdirSync\b/, + /\bfs\.accessSync\b/, + /\bfs\.readdirSync\b/, + /\bfs\.unlinkSync\b/, + /\bfs\.appendFileSync\b/, + /\bfs\.renameSync\b/, + /\bfs\.copyFileSync\b/, + /\bfs\.chmodSync\b/, + /\bfs\.rmdirSync\b/, + /\bfs\.openSync\b/, + /\bfs\.closeSync\b/, + /\bfs\.readSync\b/, + /\bfs\.writeSync\b/, +]; + +const SYNC_IO_ALLOW_COMMENT = "sync-io-allowed"; + +function checkSyncIO(filePath, content) { + // Skip test files + if (filePath.endsWith(".test.ts")) return []; + + const lines = content.split("\n"); + const issues = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.includes(SYNC_IO_ALLOW_COMMENT)) continue; + + for (const pattern of SYNC_IO_PATTERNS) { + if (pattern.test(line)) { + issues.push({ + line: i + 1, + text: `sync I/O (${pattern.source}): ${line.trim().slice(0, 100)}`, + }); + break; + } + } + } + + return issues; +} + +// ── Checker registry ── +// To add a new check: add { name, check(filePath, content) => Issue[] } here. + +const CHECKERS = [ + { name: "duplicate-blocks", check: checkDuplicates }, + { name: "sync-io", check: checkSyncIO }, +]; + +// ── Main ── + +const files = walkDir(SRC_DIR); +const allIssues = new Map(); // filePath → Issue[] + +for (const file of files) { + const content = fs.readFileSync(file, "utf8"); + for (const checker of CHECKERS) { + const issues = checker.check(file, content); + for (const issue of issues) { + const key = relPath(file); + if (!allIssues.has(key)) allIssues.set(key, []); + allIssues.get(key).push({ ...issue, checker: checker.name }); + } + } +} + +let totalIssues = 0; +for (const [file, issues] of allIssues) { + for (const issue of issues) { + totalIssues++; + console.error( + `❌ ${file}:${issue.line} [${issue.checker}] ${issue.text}`, + ); + } +} + +if (totalIssues > 0) { + console.error( + `\n❌ ${totalIssues} code quality issue(s) found. Fix them or add an inline suppression comment if justified.`, + ); + process.exit(1); +} else { + console.log( + `✅ All ${CHECKERS.length} code quality checks passed (${files.length} files scanned).`, + ); + process.exit(0); +} diff --git a/tasksync-chat/scripts/check-duplicates.js b/tasksync-chat/scripts/check-duplicates.js deleted file mode 100644 index fded47e..0000000 --- a/tasksync-chat/scripts/check-duplicates.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Scans TypeScript source files for consecutive duplicate code blocks. - * - * Catches corruption from multi-line replacement tools (e.g. the triple - * notifyQueueChanged bug) by detecting 3+ identical consecutive lines - * in the same file. - * - * Usage: node scripts/check-duplicates.js - * Exit code 0 = clean, 1 = duplicates found - */ - -const fs = require("node:fs"); -const path = require("node:path"); - -const SRC_DIR = path.join(__dirname, "..", "src"); -const MIN_CONSECUTIVE = 3; // Flag when 3+ identical lines repeat consecutively -const EXTENSIONS = [".ts", ".js"]; -const IGNORE_PATTERNS = [ - /^\s*$/, // Empty lines - /^\s*\/\//, // Single-line comments - /^\s*\*/, // JSDoc / block comment lines - /^\s*[{}]\s*$/, // Lone braces - /^\s*import\s/, // Import lines -]; - -function shouldIgnoreLine(line) { - return IGNORE_PATTERNS.some((pattern) => pattern.test(line)); -} - -function scanFile(filePath) { - const content = fs.readFileSync(filePath, "utf8"); - const lines = content.split("\n"); - const issues = []; - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - - if (shouldIgnoreLine(line)) { - i++; - continue; - } - - // Count consecutive identical lines - let count = 1; - while (i + count < lines.length && lines[i + count] === line) { - count++; - } - - if (count >= MIN_CONSECUTIVE) { - issues.push({ - line: i + 1, - count, - text: line.trim().slice(0, 80), - }); - } - - // Also check for repeated multi-line blocks (2-5 line patterns) - for (let blockSize = 2; blockSize <= 5; blockSize++) { - if (i + blockSize * 2 > lines.length) break; - - const block = lines.slice(i, i + blockSize).join("\n"); - // Skip if block is all ignorable - if (lines.slice(i, i + blockSize).every(shouldIgnoreLine)) break; - - let blockCount = 1; - let offset = blockSize; - while (i + offset + blockSize <= lines.length) { - const nextBlock = lines.slice(i + offset, i + offset + blockSize).join("\n"); - if (nextBlock === block) { - blockCount++; - offset += blockSize; - } else { - break; - } - } - - if (blockCount >= MIN_CONSECUTIVE) { - issues.push({ - line: i + 1, - count: blockCount, - text: `[${blockSize}-line block] ${lines[i].trim().slice(0, 60)}...`, - }); - } - } - - i++; - } - - return issues; -} - -function walkDir(dir) { - const results = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === "__mocks__") continue; - results.push(...walkDir(fullPath)); - } else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { - results.push(fullPath); - } - } - return results; -} - -// ── Main ── - -const files = walkDir(SRC_DIR); -let hasIssues = false; - -for (const file of files) { - const issues = scanFile(file); - if (issues.length > 0) { - hasIssues = true; - const relPath = path.relative(path.join(__dirname, ".."), file); - for (const issue of issues) { - console.error( - `❌ ${relPath}:${issue.line} — ${issue.count}x consecutive duplicate: ${issue.text}`, - ); - } - } -} - -if (hasIssues) { - console.error("\n❌ Duplicate blocks detected! This may indicate tool corruption."); - process.exit(1); -} else { - console.log("✅ No duplicate blocks found."); - process.exit(0); -} diff --git a/tasksync-chat/src/mcp/mcpServer.ts b/tasksync-chat/src/mcp/mcpServer.ts index 63c89f4..7c7e0b0 100644 --- a/tasksync-chat/src/mcp/mcpServer.ts +++ b/tasksync-chat/src/mcp/mcpServer.ts @@ -66,7 +66,7 @@ export class McpServerManager { private readonly SESSION_REAP_INTERVAL_MS = 60 * 1000; // Check every minute private _isRunning: boolean = false; - constructor(private provider: TaskSyncWebviewProvider) {} + constructor(private provider: TaskSyncWebviewProvider) { } /** * Check if MCP server is currently running @@ -150,15 +150,15 @@ export class McpServerManager { | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } > = [ - { - type: "text", - text: JSON.stringify({ - ...result, - instruction: - "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", - }), - }, - ]; + { + type: "text", + text: JSON.stringify({ + ...result, + instruction: + "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", + }), + }, + ]; if (result.attachments?.length) { const imageParts = await Promise.all( @@ -300,7 +300,7 @@ export class McpServerManager { * Periodically close idle sessions to prevent unbounded memory growth. */ private startSessionReaper(): void { - this.sessionReapInterval = setInterval(() => { + this.sessionReapInterval = setInterval(async () => { const now = Date.now(); const expired: string[] = []; for (const [sessionId, timestamp] of this.sessionTimestamps) { @@ -310,18 +310,18 @@ export class McpServerManager { } for (const sessionId of expired) { const transport = this.transports.get(sessionId); + this.transports.delete(sessionId); + this.sessionTimestamps.delete(sessionId); if (transport) { - transport - .close() - .catch((e) => - console.error( - `[TaskSync MCP] Error closing stale session ${sessionId}:`, - e, - ), + try { + await transport.close(); + } catch (e) { + console.error( + `[TaskSync MCP] Error closing stale session ${sessionId}:`, + e, ); + } } - this.transports.delete(sessionId); - this.sessionTimestamps.delete(sessionId); } }, this.SESSION_REAP_INTERVAL_MS); } @@ -388,7 +388,10 @@ export class McpServerManager { config.mcpServers = {}; } - config.mcpServers[serverName] = serverConfig; + config.mcpServers[serverName] = { + ...config.mcpServers[serverName], + ...serverConfig, + }; await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); } catch (error) { console.error( diff --git a/tasksync-chat/src/server/remoteHtmlService.ts b/tasksync-chat/src/server/remoteHtmlService.ts index f4e221f..a128e54 100644 --- a/tasksync-chat/src/server/remoteHtmlService.ts +++ b/tasksync-chat/src/server/remoteHtmlService.ts @@ -43,7 +43,7 @@ export class RemoteHtmlService { constructor( private webDir: string, private mediaDir: string, - ) {} + ) { } /** * Preload HTML templates asynchronously during server startup. @@ -110,7 +110,7 @@ export class RemoteHtmlService { // Route: /shared-constants.js - serve shared constants (SSOT for frontend) if (url.pathname === "/shared-constants.js") { const sharedConstantsPath = path.join(this.webDir, "shared-constants.js"); - this.serveFile(sharedConstantsPath, res); + void this.serveFile(sharedConstantsPath, res); return; } @@ -135,7 +135,7 @@ export class RemoteHtmlService { return; } - this.serveFile(fullPath, res); + void this.serveFile(fullPath, res); return; } @@ -149,7 +149,7 @@ export class RemoteHtmlService { "dist", "codicon.css", ); - this.serveFile(codiconPath, res); + void this.serveFile(codiconPath, res); return; } @@ -163,7 +163,7 @@ export class RemoteHtmlService { "dist", "codicon.ttf", ); - this.serveFile(codiconPath, res); + void this.serveFile(codiconPath, res); return; } @@ -195,7 +195,7 @@ export class RemoteHtmlService { const isLoginPage = url.pathname === "/" || url.pathname === "/index.html"; const cspHeader = isLoginPage ? this.buildLoginCsp(requestHost) : undefined; - this.serveFile( + void this.serveFile( fullPath, res, () => { @@ -215,12 +215,12 @@ export class RemoteHtmlService { * Uses realpath to atomically resolve symlinks and verify the canonical path * is within allowed directories, preventing TOCTOU race conditions. */ - serveFile( + async serveFile( fullPath: string, res: http.ServerResponse, onNotFound?: () => void, cspOverride?: string, - ): void { + ): Promise { const ext = path.extname(fullPath).toLowerCase(); const canonicalWebDir = path.resolve(this.webDir); const canonicalMediaDir = path.resolve(this.mediaDir); @@ -230,57 +230,58 @@ export class RemoteHtmlService { "node_modules", ); - // Atomically resolve symlinks and verify the canonical path - fs.realpath(fullPath, (realpathErr, resolvedPath) => { - if (realpathErr) { - if (onNotFound) { - onNotFound(); - } else { - res.writeHead(404); - res.end("Not Found"); - } - return; + let resolvedPath: string; + try { + resolvedPath = await fs.promises.realpath(fullPath); + } catch { + if (onNotFound) { + onNotFound(); + } else { + res.writeHead(404); + res.end("Not Found"); } + return; + } - // Verify resolved path is within allowed directories - const inWebDir = - resolvedPath.startsWith(canonicalWebDir + path.sep) || - resolvedPath === canonicalWebDir; - const inMediaDir = - resolvedPath.startsWith(canonicalMediaDir + path.sep) || - resolvedPath === canonicalMediaDir; - const inNodeModules = resolvedPath.startsWith( - canonicalNodeModules + path.sep, - ); - if (!inWebDir && !inMediaDir && !inNodeModules) { - res.writeHead(403); - res.end("Forbidden"); - return; - } + // Verify resolved path is within allowed directories + const inWebDir = + resolvedPath.startsWith(canonicalWebDir + path.sep) || + resolvedPath === canonicalWebDir; + const inMediaDir = + resolvedPath.startsWith(canonicalMediaDir + path.sep) || + resolvedPath === canonicalMediaDir; + const inNodeModules = resolvedPath.startsWith( + canonicalNodeModules + path.sep, + ); + if (!inWebDir && !inMediaDir && !inNodeModules) { + res.writeHead(403); + res.end("Forbidden"); + return; + } - fs.readFile(resolvedPath, (err, data) => { - if (err) { - if (onNotFound) { - onNotFound(); - } else { - res.writeHead(404); - res.end("Not Found"); - } - return; - } + let data: Buffer; + try { + data = await fs.promises.readFile(resolvedPath); + } catch { + if (onNotFound) { + onNotFound(); + } else { + res.writeHead(404); + res.end("Not Found"); + } + return; + } - const headers: Record = { - "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", - "Cache-Control": "no-cache", - }; - if (cspOverride) { - headers["Content-Security-Policy"] = cspOverride; - } - setSecurityHeaders(res, this.tlsEnabled); - res.writeHead(200, headers); - res.end(data); - }); - }); + const headers: Record = { + "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", + "Cache-Control": "no-cache", + }; + if (cspOverride) { + headers["Content-Security-Policy"] = cspOverride; + } + setSecurityHeaders(res, this.tlsEnabled); + res.writeHead(200, headers); + res.end(data); } /** @@ -293,9 +294,9 @@ export class RemoteHtmlService { res.end(html); } - /** Build WebSocket origin directives from the request host. Falls back to broad `ws: wss:` if empty. */ + /** Build WebSocket origin directives from the request host. Returns empty string if host is unavailable. */ private buildWsOrigin(host: string): string { - if (!host) return "ws: wss:"; + if (!host) return ""; return `ws://${host} wss://${host}`; } diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index fdb4516..3d622b3 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -213,16 +213,16 @@ export function registerTools( | vscode.LanguageModelTextPart | vscode.LanguageModelDataPart )[] = [ - new vscode.LanguageModelTextPart( - JSON.stringify({ - response: result.response, - queued: result.queue, - attachmentCount: result.attachments.length, - instruction: - "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", - }), - ), - ]; + new vscode.LanguageModelTextPart( + JSON.stringify({ + response: result.response, + queued: result.queue, + attachmentCount: result.attachments.length, + instruction: + "The user can ONLY see messages sent via this tool. After processing this response, you MUST call askUser again to report results. NEVER end your turn without calling askUser.", + }), + ), + ]; // Add image attachments as LanguageModelDataPart for vision models if (result.attachments && result.attachments.length > 0) { @@ -232,7 +232,9 @@ export function registerTools( const filePath = fileUri.fsPath; // Check if file exists - if (!fs.existsSync(filePath)) { + try { + await fs.promises.access(filePath); + } catch { console.error( "[TaskSync] Attachment file does not exist:", filePath, diff --git a/tasksync-chat/src/webview/fileHandlers.ts b/tasksync-chat/src/webview/fileHandlers.ts index 8edeb97..3f7c6db 100644 --- a/tasksync-chat/src/webview/fileHandlers.ts +++ b/tasksync-chat/src/webview/fileHandlers.ts @@ -305,9 +305,7 @@ export async function handleSaveImage( } const tempDir = path.join(storageUri.fsPath, "temp-images"); - if (!fs.existsSync(tempDir)) { - await fs.promises.mkdir(tempDir, { recursive: true }); - } + await fs.promises.mkdir(tempDir, { recursive: true }); const existingImages = p._attachments.filter( (a: AttachmentInfo) => a.isTemporary, @@ -319,7 +317,12 @@ export async function handleSaveImage( let filePath = path.join(tempDir, fileName); let counter = existingImages; - while (fs.existsSync(filePath)) { + while ( + await fs.promises + .access(filePath) + .then(() => true) + .catch(() => false) + ) { counter++; fileName = `image-pasted-${counter}${ext}`; filePath = path.join(tempDir, fileName); @@ -420,7 +423,7 @@ export async function handleOpenFileLink(target: string): Promise { return; } - const fileUri = resolveFileLinkUri(parsedTarget.filePath); + const fileUri = await resolveFileLinkUri(parsedTarget.filePath); if (!fileUri) { vscode.window.showWarningMessage( `File not found: ${parsedTarget.filePath}`, @@ -548,15 +551,16 @@ export async function handleSelectContextReference( /** * Clean up temporary image files from disk by URI list. */ -export function cleanupTempImagesByUri(uris: string[]): void { +export async function cleanupTempImagesByUri(uris: string[]): Promise { for (const uri of uris) { try { const filePath = vscode.Uri.parse(uri).fsPath; - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } + await fs.promises.unlink(filePath); } catch (error) { - console.error("[TaskSync] Failed to cleanup temp image:", error); + // ENOENT is fine — file already gone + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + console.error("[TaskSync] Failed to cleanup temp image:", error); + } } } } @@ -578,6 +582,6 @@ export function cleanupTempImagesFromEntries( } } if (tempUris.length > 0) { - cleanupTempImagesByUri(tempUris); + void cleanupTempImagesByUri(tempUris); } } diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index adeb379..dd713f0 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -78,7 +78,7 @@ export function getHtmlContent( ).fsPath; if (!cachedBodyTemplate) { try { - cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); + cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); // sync-io-allowed: sync fallback when async preload misses } catch (err) { console.error( "Failed to load webview body template (sync fallback):", diff --git a/tasksync-chat/src/webview/persistence.ts b/tasksync-chat/src/webview/persistence.ts index a7bdf09..2757188 100644 --- a/tasksync-chat/src/webview/persistence.ts +++ b/tasksync-chat/src/webview/persistence.ts @@ -94,8 +94,8 @@ export async function loadPersistedHistoryFromDiskAsync(p: P): Promise { const parsed = JSON.parse(data); p._persistedHistory = Array.isArray(parsed.history) ? parsed.history - .filter((entry: ToolCallEntry) => entry.status === "completed") - .slice(0, p._MAX_HISTORY_ENTRIES) + .filter((entry: ToolCallEntry) => entry.status === "completed") + .slice(0, p._MAX_HISTORY_ENTRIES) : []; } catch (error) { console.error("[TaskSync] Failed to load persisted history:", error); @@ -174,8 +174,8 @@ export function savePersistedHistoryToDiskSync(p: P): void { const storagePath = getStorageUri(p).fsPath; const historyPath = path.join(storagePath, "tool-history.json"); - if (!fs.existsSync(storagePath)) { - fs.mkdirSync(storagePath, { recursive: true }); + if (!fs.existsSync(storagePath)) { // sync-io-allowed: deactivation hook must complete before process exits + fs.mkdirSync(storagePath, { recursive: true }); // sync-io-allowed } const completedHistory = p._persistedHistory.filter( @@ -184,7 +184,7 @@ export function savePersistedHistoryToDiskSync(p: P): void { let merged = completedHistory; try { - const existing = fs.readFileSync(historyPath, "utf8"); + const existing = fs.readFileSync(historyPath, "utf8"); // sync-io-allowed const parsed = JSON.parse(existing); if (Array.isArray(parsed.history)) { merged = mergeAndDedup( @@ -200,7 +200,7 @@ export function savePersistedHistoryToDiskSync(p: P): void { p._persistedHistory = merged; const data = JSON.stringify({ history: merged }, null, 2); - fs.writeFileSync(historyPath, data, "utf8"); + fs.writeFileSync(historyPath, data, "utf8"); // sync-io-allowed p._historyDirty = false; } catch (error) { console.error("[TaskSync] Failed to save persisted history:", error); diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index 590470e..49b2e86 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -178,7 +178,7 @@ describe("handleAddQueuePrompt", () => { const resolve = vi.fn(); const pendingRequests = new Map(); pendingRequests.set("tc_1", resolve); - const timer = setTimeout(() => {}, 10000); + const timer = setTimeout(() => { }, 10000); const p = createMockP({ _queueEnabled: true, diff --git a/tasksync-chat/src/webview/webviewUtils.test.ts b/tasksync-chat/src/webview/webviewUtils.test.ts index 2f5ed16..d3f3743 100644 --- a/tasksync-chat/src/webview/webviewUtils.test.ts +++ b/tasksync-chat/src/webview/webviewUtils.test.ts @@ -17,6 +17,9 @@ import { vi.mock("fs", () => ({ existsSync: vi.fn(() => false), statSync: vi.fn(() => ({ isFile: () => true })), + promises: { + stat: vi.fn(() => Promise.reject(new Error("ENOENT"))), + }, })); // ─── formatElapsed ─────────────────────────────────────────── @@ -381,58 +384,60 @@ describe("markSessionTerminated", () => { describe("resolveFileLinkUri", () => { afterEach(() => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.promises.stat).mockRejectedValue(new Error("ENOENT")); }); - it("returns null for empty string", () => { - expect(resolveFileLinkUri("")).toBeNull(); + it("returns null for empty string", async () => { + expect(await resolveFileLinkUri("")).toBeNull(); }); - it("returns null for whitespace-only string", () => { - expect(resolveFileLinkUri(" ")).toBeNull(); + it("returns null for whitespace-only string", async () => { + expect(await resolveFileLinkUri(" ")).toBeNull(); }); - it("returns null when file does not exist (absolute path)", () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - expect(resolveFileLinkUri("/nonexistent/file.ts")).toBeNull(); + it("returns null when file does not exist (absolute path)", async () => { + vi.mocked(fs.promises.stat).mockRejectedValue(new Error("ENOENT")); + expect(await resolveFileLinkUri("/nonexistent/file.ts")).toBeNull(); }); - it("returns Uri for existing absolute path", () => { + it("returns Uri for existing absolute path", async () => { // Make Uri.parse throw so it falls through to isAbsolute check const origParse = vscode.Uri.parse; (vscode.Uri as any).parse = () => { throw new Error("not a URI"); }; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); - const result = resolveFileLinkUri("/existing/file.ts"); + vi.mocked(fs.promises.stat).mockResolvedValue({ + isFile: () => true, + } as any); + const result = await resolveFileLinkUri("/existing/file.ts"); expect(result).not.toBeNull(); (vscode.Uri as any).parse = origParse; }); - it("returns null for absolute path that is a directory", () => { + it("returns null for absolute path that is a directory", async () => { const origParse = vscode.Uri.parse; (vscode.Uri as any).parse = () => { throw new Error("not a URI"); }; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isFile: () => false } as any); - expect(resolveFileLinkUri("/some/directory")).toBeNull(); + vi.mocked(fs.promises.stat).mockResolvedValue({ + isFile: () => false, + } as any); + expect(await resolveFileLinkUri("/some/directory")).toBeNull(); (vscode.Uri as any).parse = origParse; }); - it("strips leading ./ from relative paths", () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + it("strips leading ./ from relative paths", async () => { + vi.mocked(fs.promises.stat).mockRejectedValue(new Error("ENOENT")); // Should normalize path and not crash - expect(resolveFileLinkUri("./src/file.ts")).toBeNull(); + expect(await resolveFileLinkUri("./src/file.ts")).toBeNull(); }); - it("returns null for relative path with no workspace folders", () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - expect(resolveFileLinkUri("src/file.ts")).toBeNull(); + it("returns null for relative path with no workspace folders", async () => { + vi.mocked(fs.promises.stat).mockRejectedValue(new Error("ENOENT")); + expect(await resolveFileLinkUri("src/file.ts")).toBeNull(); }); - it("resolves relative path against workspace folders", () => { + it("resolves relative path against workspace folders", async () => { const origParse = vscode.Uri.parse; (vscode.Uri as any).parse = () => { throw new Error("not a URI"); @@ -441,17 +446,18 @@ describe("resolveFileLinkUri", () => { (vscode.workspace as any).workspaceFolders = [ { uri: { fsPath: "/workspace" } }, ]; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); + vi.mocked(fs.promises.stat).mockResolvedValue({ + isFile: () => true, + } as any); - const result = resolveFileLinkUri("src/file.ts"); + const result = await resolveFileLinkUri("src/file.ts"); expect(result).not.toBeNull(); (vscode.workspace as any).workspaceFolders = original; (vscode.Uri as any).parse = origParse; }); - it("returns null when relative file not found in any workspace folder", () => { + it("returns null when relative file not found in any workspace folder", async () => { const origParse = vscode.Uri.parse; (vscode.Uri as any).parse = () => { throw new Error("not a URI"); @@ -460,19 +466,20 @@ describe("resolveFileLinkUri", () => { (vscode.workspace as any).workspaceFolders = [ { uri: { fsPath: "/workspace" } }, ]; - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.promises.stat).mockRejectedValue(new Error("ENOENT")); - expect(resolveFileLinkUri("nonexistent/file.ts")).toBeNull(); + expect(await resolveFileLinkUri("nonexistent/file.ts")).toBeNull(); (vscode.workspace as any).workspaceFolders = original; (vscode.Uri as any).parse = origParse; }); - it("resolves file:// URI scheme", () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); + it("resolves file:// URI scheme", async () => { + vi.mocked(fs.promises.stat).mockResolvedValue({ + isFile: () => true, + } as any); - const result = resolveFileLinkUri("file:///some/file.ts"); + const result = await resolveFileLinkUri("file:///some/file.ts"); expect(result).not.toBeNull(); }); }); diff --git a/tasksync-chat/src/webview/webviewUtils.ts b/tasksync-chat/src/webview/webviewUtils.ts index ea66f21..8c8bce4 100644 --- a/tasksync-chat/src/webview/webviewUtils.ts +++ b/tasksync-chat/src/webview/webviewUtils.ts @@ -214,10 +214,24 @@ export function parseFileLinkTarget(target: string): { return { filePath, startLine, endLine }; } +/** + * Check whether a path exists and is a regular file. + */ +async function isFile(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + /** * Resolve a file link path to an existing file URI. */ -export function resolveFileLinkUri(rawPath: string): vscode.Uri | null { +export async function resolveFileLinkUri( + rawPath: string, +): Promise { const normalizedPath = rawPath.trim().replace(/^\.\//, "").trim(); if (!normalizedPath) { return null; @@ -225,11 +239,7 @@ export function resolveFileLinkUri(rawPath: string): vscode.Uri | null { try { const parsedUri = vscode.Uri.parse(normalizedPath); - if ( - parsedUri.scheme === "file" && - fs.existsSync(parsedUri.fsPath) && - fs.statSync(parsedUri.fsPath).isFile() - ) { + if (parsedUri.scheme === "file" && (await isFile(parsedUri.fsPath))) { return parsedUri; } } catch { @@ -237,7 +247,7 @@ export function resolveFileLinkUri(rawPath: string): vscode.Uri | null { } if (path.isAbsolute(normalizedPath)) { - if (fs.existsSync(normalizedPath) && fs.statSync(normalizedPath).isFile()) { + if (await isFile(normalizedPath)) { return vscode.Uri.file(path.resolve(normalizedPath)); } return null; @@ -250,7 +260,7 @@ export function resolveFileLinkUri(rawPath: string): vscode.Uri | null { for (const folder of workspaceFolders) { const candidatePath = path.resolve(folder.uri.fsPath, normalizedPath); - if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) { + if (await isFile(candidatePath)) { return vscode.Uri.file(candidatePath); } } From 412abb9815a638e2203b56459a8d86a917830cc5 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 13:06:38 +0400 Subject: [PATCH 27/35] fix: resolve Copilot review comments (round 8) - Fix reversed .includes() logic in context autocomplete suggestions - Fix shouldAutoRespond type coercion (string|false -> boolean) - Tighten index validation from isFinite to isInteger + >= 0 - Tighten esbuild regex to require semicolon after number literal --- tasksync-chat/esbuild.js | 7 ++++--- tasksync-chat/src/context/index.ts | 4 ++-- tasksync-chat/src/server/remoteSettingsHandler.ts | 6 +++--- tasksync-chat/src/webview/queueHandlers.ts | 4 +++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tasksync-chat/esbuild.js b/tasksync-chat/esbuild.js index 1562d79..cca3324 100644 --- a/tasksync-chat/esbuild.js +++ b/tasksync-chat/esbuild.js @@ -15,10 +15,11 @@ function generateSharedConstants() { ); // Extract simple numeric constants: export const NAME = ; - // NOTE: This regex only supports integer literals. If a constant becomes an expression - // (e.g., 5 * 60 * 1000), extractNum() will throw at build time — this is intentional. + // NOTE: This regex only supports standalone integer literal assignments. + // If a constant becomes an expression (e.g., 5 * 60 * 1000), extractNum() + // will throw at build time — this is intentional and enforced by the regex. function extractNum(name) { - const m = source.match(new RegExp(`export const ${name}\\s*=\\s*(\\d+)`)); + const m = source.match(new RegExp(`export const ${name}\\s*=\\s*(\\d+)\\s*;`)); if (!m) throw new Error(`Failed to extract ${name} from remoteConstants.ts`); return Number(m[1]); diff --git a/tasksync-chat/src/context/index.ts b/tasksync-chat/src/context/index.ts index c113ea1..30d590b 100644 --- a/tasksync-chat/src/context/index.ts +++ b/tasksync-chat/src/context/index.ts @@ -82,7 +82,7 @@ export class ContextManager { const lowerQuery = query.toLowerCase().replace("@", ""); // Terminal suggestions - if ("terminal".includes(lowerQuery) || lowerQuery.startsWith("term")) { + if ("terminal".startsWith(lowerQuery) || lowerQuery.startsWith("term")) { const commands = this._terminalProvider.formatCommandListForAutocomplete(); if (commands.length > 0) { @@ -103,7 +103,7 @@ export class ContextManager { } // Problems suggestions - if ("problems".includes(lowerQuery) || lowerQuery.startsWith("prob")) { + if ("problems".startsWith(lowerQuery) || lowerQuery.startsWith("prob")) { const problemsInfo = this._problemsProvider.formatForAutocomplete(); suggestions.push({ type: "problems", diff --git a/tasksync-chat/src/server/remoteSettingsHandler.ts b/tasksync-chat/src/server/remoteSettingsHandler.ts index a3256da..2594b15 100644 --- a/tasksync-chat/src/server/remoteSettingsHandler.ts +++ b/tasksync-chat/src/server/remoteSettingsHandler.ts @@ -113,7 +113,7 @@ export async function dispatchSettingsMessage( case "editAutopilotPrompt": { const index = Number(msg.index); const prompt = typeof msg.prompt === "string" ? msg.prompt : ""; - if (!Number.isFinite(index) || !prompt.trim()) { + if (!Number.isInteger(index) || index < 0 || !prompt.trim()) { sendWsError(ws, "Invalid input", ErrorCode.INVALID_INPUT); return true; } @@ -124,7 +124,7 @@ export async function dispatchSettingsMessage( case "removeAutopilotPrompt": { const index = Number(msg.index); - if (!Number.isFinite(index)) { + if (!Number.isInteger(index) || index < 0) { sendWsError(ws, "Invalid index", ErrorCode.INVALID_INPUT); return true; } @@ -136,7 +136,7 @@ export async function dispatchSettingsMessage( case "reorderAutopilotPrompts": { const from = Number(msg.fromIndex); const to = Number(msg.toIndex); - if (!Number.isFinite(from) || !Number.isFinite(to)) { + if (!Number.isInteger(from) || from < 0 || !Number.isInteger(to) || to < 0) { sendWsError(ws, "Invalid indices", ErrorCode.INVALID_INPUT); return true; } diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index 53a9b8e..e6f9f94 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -49,7 +49,9 @@ export function handleAddQueuePrompt( // Check if we should auto-respond BEFORE adding to queue (race condition fix) const currentCallId = p._currentToolCallId; const shouldAutoRespond = - p._queueEnabled && currentCallId && p._pendingRequests.has(currentCallId); + !!p._queueEnabled && + currentCallId !== null && + p._pendingRequests.has(currentCallId); let handledAsToolResponse = false; From 7b1f1198602e1413253e1a7c20ace1dbc45c2d2f Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 15:09:30 +0400 Subject: [PATCH 28/35] fix: dispose cancelled flag, stale-execution timestamp, test organization - lifecycleHandlers: add cancelled:true when resolving pending requests on dispose so tools.ts treats it as cancellation, not a real user response - terminalContext: update tracker.timestamp on each data chunk in _readExecutionOutput to prevent stale cleanup of long-running commands - terminalContext: use ExecutionTracker type instead of inline { output: string[] } - gitService.test: move 'allows backslash' test from invalid-paths to valid-paths describe block (it asserts toBe(true)) --- tasksync-chat/src/context/terminalContext.ts | 4 +++- tasksync-chat/src/server/gitService.test.ts | 10 +++++----- tasksync-chat/src/webview/lifecycleHandlers.ts | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index 8ee2358..1b1311a 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -150,7 +150,7 @@ export class TerminalContextProvider implements vscode.Disposable { */ private async _readExecutionOutput( execution: vscode.TerminalShellExecution, - tracker: { output: string[] }, + tracker: ExecutionTracker, ): Promise { try { const stream = execution.read(); @@ -158,6 +158,8 @@ export class TerminalContextProvider implements vscode.Disposable { for await (const data of stream) { tracker.output.push(data); outputSize += data.length; + // Update timestamp so stale-execution cleanup doesn't kill active streams + tracker.timestamp = Date.now(); // Limit output size to prevent memory issues (max 50KB per command) if (outputSize > this._MAX_OUTPUT_BYTES) { diff --git a/tasksync-chat/src/server/gitService.test.ts b/tasksync-chat/src/server/gitService.test.ts index 46ff8ba..a014948 100644 --- a/tasksync-chat/src/server/gitService.test.ts +++ b/tasksync-chat/src/server/gitService.test.ts @@ -33,6 +33,11 @@ describe("isValidFilePath", () => { expect(isValidFilePath("my file.txt")).toBe(true); expect(isValidFilePath("path/with spaces/file.js")).toBe(true); }); + + it("allows paths with backslash (Windows paths)", () => { + expect(isValidFilePath("file\\name")).toBe(true); + expect(isValidFilePath("src\\app.ts")).toBe(true); + }); }); describe("invalid paths", () => { @@ -59,11 +64,6 @@ describe("isValidFilePath", () => { expect(isValidFilePath("file'name")).toBe(false); }); - it("allows paths with backslash (Windows paths)", () => { - expect(isValidFilePath("file\\name")).toBe(true); - expect(isValidFilePath("src\\app.ts")).toBe(true); - }); - it("rejects paths with null bytes", () => { expect(isValidFilePath("file\x00name")).toBe(false); }); diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index dd713f0..8c3f008 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -197,10 +197,11 @@ export function disposeProvider(p: P): void { p._fileSearchCache.clear(); p._currentSessionCallsMap.clear(); - // Reject all pending requests so callers don't hang forever + // Resolve all pending requests with cancelled flag so callers don't hang forever. + // The cancelled flag ensures tools.ts treats this as a cancellation, not a real user response. for (const [id, resolve] of p._pendingRequests) { debugLog(`disposeProvider — rejecting pending request ${id}`); - resolve({ value: "[Extension disposed]", queue: false, attachments: [] }); + resolve({ value: "[Extension disposed]", queue: false, attachments: [], cancelled: true }); } p._pendingRequests.clear(); From 477d1481a9849e908df7a18cec07f4a602e98614 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 15:26:03 +0400 Subject: [PATCH 29/35] fix: import path, resolve formatting, rename _MAX_OUTPUT_BYTES - settingsHandlers.test.ts: shorten import from ../webview/ to ./ (file is already in src/webview/) - lifecycleHandlers.ts: multi-line format the dispose resolve() call - terminalContext.ts: rename _MAX_OUTPUT_BYTES to _MAX_OUTPUT_CHARS since string.length counts UTF-16 code units, not bytes --- tasksync-chat/src/context/terminalContext.ts | 6 +++--- tasksync-chat/src/webview/lifecycleHandlers.ts | 7 ++++++- tasksync-chat/src/webview/settingsHandlers.test.ts | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tasksync-chat/src/context/terminalContext.ts b/tasksync-chat/src/context/terminalContext.ts index 1b1311a..8c028b9 100644 --- a/tasksync-chat/src/context/terminalContext.ts +++ b/tasksync-chat/src/context/terminalContext.ts @@ -38,8 +38,8 @@ export class TerminalContextProvider implements vscode.Disposable { // Cleanup stale executions after 5 minutes (prevents memory leak if terminal killed, // but allows long-running commands like npm install to complete) private readonly _STALE_EXECUTION_TIMEOUT_MS = 300000; - // Max output bytes per command to prevent memory issues (~50KB) - private readonly _MAX_OUTPUT_BYTES = 50000; + // Max output chars per command to prevent memory issues (~50KB for ASCII) + private readonly _MAX_OUTPUT_CHARS = 50000; private _cleanupInterval: ReturnType | null = null; constructor() { @@ -162,7 +162,7 @@ export class TerminalContextProvider implements vscode.Disposable { tracker.timestamp = Date.now(); // Limit output size to prevent memory issues (max 50KB per command) - if (outputSize > this._MAX_OUTPUT_BYTES) { + if (outputSize > this._MAX_OUTPUT_CHARS) { tracker.output.push("\n... (output truncated)"); break; } diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index 8c3f008..c9b67e9 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -201,7 +201,12 @@ export function disposeProvider(p: P): void { // The cancelled flag ensures tools.ts treats this as a cancellation, not a real user response. for (const [id, resolve] of p._pendingRequests) { debugLog(`disposeProvider — rejecting pending request ${id}`); - resolve({ value: "[Extension disposed]", queue: false, attachments: [], cancelled: true }); + resolve({ + value: "[Extension disposed]", + queue: false, + attachments: [], + cancelled: true, + }); } p._pendingRequests.clear(); diff --git a/tasksync-chat/src/webview/settingsHandlers.test.ts b/tasksync-chat/src/webview/settingsHandlers.test.ts index 8324bd4..6414d05 100644 --- a/tasksync-chat/src/webview/settingsHandlers.test.ts +++ b/tasksync-chat/src/webview/settingsHandlers.test.ts @@ -3,7 +3,7 @@ import { RESPONSE_TIMEOUT_ALLOWED_VALUES, RESPONSE_TIMEOUT_DEFAULT_MINUTES, } from "../constants/remoteConstants"; -import { normalizeResponseTimeout } from "../webview/settingsHandlers"; +import { normalizeResponseTimeout } from "./settingsHandlers"; describe("normalizeResponseTimeout", () => { it("accepts valid allowed values", () => { From fec6642256c2d9e204ab308de80a44a8affd952f Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 15:26:33 +0400 Subject: [PATCH 30/35] fix: correct formatting in index.ts and remoteSettingsHandler.ts --- tasksync-chat/src/context/index.ts | 2 +- tasksync-chat/src/server/remoteSettingsHandler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tasksync-chat/src/context/index.ts b/tasksync-chat/src/context/index.ts index 30d590b..50cf8eb 100644 --- a/tasksync-chat/src/context/index.ts +++ b/tasksync-chat/src/context/index.ts @@ -9,7 +9,7 @@ export { ProblemInfo, ProblemsContextProvider, - ProblemsSummary, + ProblemsSummary } from "./problemsContext"; export { TerminalCommand, TerminalContextProvider } from "./terminalContext"; diff --git a/tasksync-chat/src/server/remoteSettingsHandler.ts b/tasksync-chat/src/server/remoteSettingsHandler.ts index 2594b15..5f01ac6 100644 --- a/tasksync-chat/src/server/remoteSettingsHandler.ts +++ b/tasksync-chat/src/server/remoteSettingsHandler.ts @@ -16,7 +16,7 @@ export async function dispatchSettingsMessage( ws: WebSocket, provider: P, broadcastFn: (type: string, data: unknown) => void, - msg: { type: string; [key: string]: unknown }, + msg: { type: string;[key: string]: unknown }, ): Promise { switch (msg.type) { case "updateSoundSetting": From afbb6d487dbf8f85de3c7d9c263431f7b7350d39 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 15:43:07 +0400 Subject: [PATCH 31/35] fix: safe server.close, no-op reorder guard, unref sound processes - serverUtils: wrap server.close() in try/catch in isPortAvailable error handler to prevent secondary errors when server never started listening - queueHandlers: early return when fromIndex === toIndex in handleReorderQueue to avoid unnecessary writes/broadcasts - sessionManager: spawn sound processes with stdio:'ignore', windowsHide, and child.unref() to prevent keeping the extension host event loop alive --- tasksync-chat/src/server/serverUtils.ts | 6 +++++- tasksync-chat/src/webview/queueHandlers.ts | 1 + tasksync-chat/src/webview/sessionManager.ts | 12 ++++++++---- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tasksync-chat/src/server/serverUtils.ts b/tasksync-chat/src/server/serverUtils.ts index 16e05ee..39fbcb4 100644 --- a/tasksync-chat/src/server/serverUtils.ts +++ b/tasksync-chat/src/server/serverUtils.ts @@ -141,7 +141,11 @@ export function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = http.createServer(); server.once("error", () => { - server.close(); + try { + server.close(); + } catch { + // Server may not have started listening — ignore close errors + } resolve(false); }); server.once("listening", () => server.close(() => resolve(true))); diff --git a/tasksync-chat/src/webview/queueHandlers.ts b/tasksync-chat/src/webview/queueHandlers.ts index e6f9f94..ede1d4f 100644 --- a/tasksync-chat/src/webview/queueHandlers.ts +++ b/tasksync-chat/src/webview/queueHandlers.ts @@ -191,6 +191,7 @@ export function handleReorderQueue( if (fromIndex < 0 || toIndex < 0) return; if (fromIndex >= p._promptQueue.length || toIndex >= p._promptQueue.length) return; + if (fromIndex === toIndex) return; const [removed] = p._promptQueue.splice(fromIndex, 1); p._promptQueue.splice(toIndex, 0, removed); diff --git a/tasksync-chat/src/webview/sessionManager.ts b/tasksync-chat/src/webview/sessionManager.ts index 30c8325..f6fd644 100644 --- a/tasksync-chat/src/webview/sessionManager.ts +++ b/tasksync-chat/src/webview/sessionManager.ts @@ -130,18 +130,22 @@ export function playSystemSound(): void { try { if (platform === "win32") { - spawn("powershell.exe", [ + const child = spawn("powershell.exe", [ "-Command", "[System.Media.SystemSounds]::Exclamation.Play()", - ]).on("error", onErr); + ], { stdio: "ignore", windowsHide: true }); + child.on("error", onErr); + child.unref(); } else if (platform === "darwin") { - execFile("afplay", ["/System/Library/Sounds/Tink.aiff"], onErr); + const child = execFile("afplay", ["/System/Library/Sounds/Tink.aiff"], onErr); + child.unref(); } else { - execFile( + const child = execFile( "paplay", ["/usr/share/sounds/freedesktop/stereo/message.oga"], onErr, ); + child.unref(); } } catch (e) { debugLog("[TaskSync] playSystemSound — sound playback error:", e); From e6ded724b64c88366422e2d6906ad6519f204268 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 15:54:40 +0400 Subject: [PATCH 32/35] fix: guard disposeProvider against non-function resolvers in pendingRequests --- .../src/webview/lifecycleHandlers.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index c9b67e9..e702450 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -200,13 +200,19 @@ export function disposeProvider(p: P): void { // Resolve all pending requests with cancelled flag so callers don't hang forever. // The cancelled flag ensures tools.ts treats this as a cancellation, not a real user response. for (const [id, resolve] of p._pendingRequests) { - debugLog(`disposeProvider — rejecting pending request ${id}`); - resolve({ - value: "[Extension disposed]", - queue: false, - attachments: [], - cancelled: true, - }); + if (typeof resolve === "function") { + debugLog(`disposeProvider — rejecting pending request ${id}`); + resolve({ + value: "[Extension disposed]", + queue: false, + attachments: [], + cancelled: true, + }); + } else { + debugLog( + `disposeProvider — pending request ${id} has non-function resolver, cleaning up`, + ); + } } p._pendingRequests.clear(); From dfd438900c3b675c3fd0aab9024099a7411a6aa5 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 16:16:48 +0400 Subject: [PATCH 33/35] fix: restore mocks, format index signature, add [TaskSync] error prefix - queueHandlers.comprehensive.test: add afterEach(vi.restoreAllMocks) in handleToggleQueue to prevent spy leak across tests - remoteSettingsHandler: apply Biome formatting (space after semicolon in index signature, line-break long condition) - lifecycleHandlers: add [TaskSync] prefix to console.error calls for consistent log filtering --- tasksync-chat/src/server/remoteSettingsHandler.ts | 9 +++++++-- tasksync-chat/src/webview/lifecycleHandlers.ts | 4 ++-- .../src/webview/queueHandlers.comprehensive.test.ts | 6 +++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tasksync-chat/src/server/remoteSettingsHandler.ts b/tasksync-chat/src/server/remoteSettingsHandler.ts index 5f01ac6..13ac41e 100644 --- a/tasksync-chat/src/server/remoteSettingsHandler.ts +++ b/tasksync-chat/src/server/remoteSettingsHandler.ts @@ -16,7 +16,7 @@ export async function dispatchSettingsMessage( ws: WebSocket, provider: P, broadcastFn: (type: string, data: unknown) => void, - msg: { type: string;[key: string]: unknown }, + msg: { type: string; [key: string]: unknown }, ): Promise { switch (msg.type) { case "updateSoundSetting": @@ -136,7 +136,12 @@ export async function dispatchSettingsMessage( case "reorderAutopilotPrompts": { const from = Number(msg.fromIndex); const to = Number(msg.toIndex); - if (!Number.isInteger(from) || from < 0 || !Number.isInteger(to) || to < 0) { + if ( + !Number.isInteger(from) || + from < 0 || + !Number.isInteger(to) || + to < 0 + ) { sendWsError(ws, "Invalid indices", ErrorCode.INVALID_INPUT); return true; } diff --git a/tasksync-chat/src/webview/lifecycleHandlers.ts b/tasksync-chat/src/webview/lifecycleHandlers.ts index e702450..292fb51 100644 --- a/tasksync-chat/src/webview/lifecycleHandlers.ts +++ b/tasksync-chat/src/webview/lifecycleHandlers.ts @@ -28,7 +28,7 @@ export async function preloadBodyTemplate( try { cachedBodyTemplate = await fs.promises.readFile(templatePath, "utf8"); } catch (err) { - console.error("Failed to preload webview body template:", err); + console.error("[TaskSync] Failed to preload webview body template:", err); cachedBodyTemplate = undefined; } } @@ -81,7 +81,7 @@ export function getHtmlContent( cachedBodyTemplate = fs.readFileSync(templatePath, "utf8"); // sync-io-allowed: sync fallback when async preload misses } catch (err) { console.error( - "Failed to load webview body template (sync fallback):", + "[TaskSync] Failed to load webview body template (sync fallback):", err, ); } diff --git a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts index 49b2e86..41efc8c 100644 --- a/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/queueHandlers.comprehensive.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; import { MAX_QUEUE_PROMPT_LENGTH, @@ -238,6 +238,10 @@ describe("handleAddQueuePrompt", () => { // ─── handleToggleQueue ────────────────────────────────────── describe("handleToggleQueue", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("enables queue and saves", () => { const p = createMockP({ _queueEnabled: false }); handleToggleQueue(p, true); From d57c0f8dd1594a9ce1bea62163c4b1d507780c26 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 16:53:55 +0400 Subject: [PATCH 34/35] docs: add TLS-optional and PWA install tips to Tailscale section --- tasksync-chat/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tasksync-chat/README.md b/tasksync-chat/README.md index 72cb306..a90fae7 100644 --- a/tasksync-chat/README.md +++ b/tasksync-chat/README.md @@ -150,6 +150,10 @@ Control TaskSync from your phone while away from your desk. Never miss an AI pro > **No exit node needed** — Tailscale creates a direct peer-to-peer connection between your devices. Traffic never leaves the encrypted tunnel. Works across different Wi-Fi networks, cellular data, and even behind NAT/firewalls. +> **TLS is optional with Tailscale** — Tailscale already encrypts all traffic end-to-end via WireGuard, so you can leave `tasksync.remoteTlsEnabled` as `false` and avoid self-signed certificate warnings in the browser. + +> **Install as a PWA** — On your phone's browser, tap the share/menu button and select "Add to Home Screen" to install TaskSync as a native-feeling app with its own icon and full-screen experience. + #### Using the PWA **Questions Tab** From b603a32222323f7d72bbe97a1c620ec156061b03 Mon Sep 17 00:00:00 2001 From: sgaabdu4 Date: Tue, 24 Mar 2026 17:54:08 +0400 Subject: [PATCH 35/35] =?UTF-8?q?fix:=20show=20'Working=E2=80=A6'=20indica?= =?UTF-8?q?tor=20immediately=20on=20remote=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After sending a response or chatMessage from the remote PWA, optimistically set isProcessingResponse=true and show the Working indicator before the server round-trip completes. Applies to all remote send paths: handleSend (submit + chatMessage), handleApprovalContinue, and handleChoicesSend. --- tasksync-chat/media/webview.js | 29 ++++++++++++++++++++---- tasksync-chat/src/webview-ui/approval.js | 6 +++++ tasksync-chat/src/webview-ui/input.js | 9 ++++++++ tasksync-chat/src/webview-ui/queue.js | 14 ++++++++---- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/tasksync-chat/media/webview.js b/tasksync-chat/media/webview.js index de3ddc8..2a81e83 100644 --- a/tasksync-chat/media/webview.js +++ b/tasksync-chat/media/webview.js @@ -2128,12 +2128,21 @@ function handleSend() { debugLog("handleSend: → chatMessage"); addChatStreamUserBubble(text); vscode.postMessage({ type: "chatMessage", content: text }); + // Show "Working…" immediately so the user knows the AI received the message + isProcessingResponse = true; + updatePendingUI(); } else { vscode.postMessage({ type: "submit", value: text, attachments: currentAttachments, }); + // In remote mode, show "Working…" optimistically while awaiting server round-trip + if (isRemoteMode && pendingToolCall) { + pendingToolCall = null; + isProcessingResponse = true; + updatePendingUI(); + } } if (chatInput) { @@ -3187,10 +3196,10 @@ function renderQueue() { let attachmentBadge = item.attachments && item.attachments.length > 0 ? '' + item.attachments.length + + ' attachment(s)" aria-label="' + + item.attachments.length + + ' attachments">' : ""; return ( '
    0 ? '' + item.attachments.length + + ' attachment(s)" aria-label="' + + item.attachments.length + + ' attachments">' : ""; return ( '