diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..f879e15 --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,19 @@ +name: Auto Assign +on: + issues: + types: [opened] + pull_request: + types: [opened] +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Auto-assign issue + uses: pozil/auto-assign-issue@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + assignees: sbaker + numOfAssignee: 1 diff --git a/LICENSE b/LICENSE index f72bff7..7f5e284 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,99 @@ -MIT License - -Copyright (c) 2026 Prompd LLC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Elastic License 2.0 + +URL: https://www.elastic.co/licensing/elastic-license + +## Acceptance + +By using the software, you agree to all of the terms and conditions below. + +## Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, +non-sublicensable, non-transferable license to use, copy, distribute, make +available, and prepare derivative works of the software, in each case subject to +the limitations and conditions below. + +## Limitations + +You may not provide the software to third parties as a hosted or managed +service, where the service provides users with access to any substantial set of +the features or functionality of the software. + +You may not move, change, disable, or circumvent the license key functionality +in the software, and you may not remove or obscure any functionality in the +software that is protected by the license key. + +You may not alter, remove, or obscure any licensing, copyright, or other notices +of the licensor in the software. Any use of the licensor's trademarks is subject +to applicable law. + +## Patents + +The licensor grants you a license, under any patent claims the licensor can +license, or becomes able to license, to make, have made, use, sell, offer for +sale, import and have imported the software, in each case subject to the +limitations and conditions in this license. This license does not cover any +patent claims that you cause to be infringed by modifications or additions to +the software. If you or your company make any written claim that the software +infringes or contributes to infringement of any patent, your patent license for +the software granted under these terms ends immediately. If your company makes +such a claim, your patent license ends immediately for work on behalf of your +company. + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you +also gets a copy of these terms. + +If you modify the software, you must include in any modified copies of the +software prominent notices stating that you have modified the software. + +## No Other Rights + +These terms do not imply any licenses other than those expressly granted in +these terms. + +## Termination + +If you use the software in violation of these terms, such use is not licensed, +and your licenses will automatically terminate. If the licensor provides you +with a notice of your violation, and you cease all violation of this license no +later than 30 days after you receive that notice, your licenses will be +reinstated retroactively. However, if you violate these terms after such +reinstatement, any additional violation of these terms will cause your licenses +to terminate automatically and permanently. + +## No Liability + +As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of +legal claim. + +## Definitions + +The "licensor" is the entity offering these terms, and the "software" is the +software the licensor makes available under these terms, including any portion +of it. + +"you" refers to the individual or entity agreeing to these terms. + +"your company" is any legal entity, sole proprietorship, or other kind of +organization that you work for, plus all organizations that have control over, +are under the control of, or are under common control with that +organization. "control" means ownership of substantially all the assets of an +entity, or the power to direct its management and policies by vote, contract, or +otherwise. Control can be direct or indirect. + +"your licenses" are all the licenses granted to you for the software under +these terms. + +"use" means anything you do with the software requiring one of your licenses. + +"trademark" means trademarks, service marks, and similar rights. + +--- + +Licensor: Logikbug LLC (dba Prompd) +Software: Prompd Desktop Application +Copyright 2025-2026 Logikbug LLC. All rights reserved. diff --git a/README.md b/README.md index 3e971a4..c08acd1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Prompd brings software engineering practices to AI prompt development. Instead o **Everything runs locally.** Your API keys, prompts, and data never leave your machine. LLM calls go directly from Prompd to provider APIs with zero intermediaries. +> **Platform:** Pre-built binaries are currently **Windows only**. Mac and Linux builds are coming soon. You can build from source on any platform today. + @@ -52,7 +54,7 @@ Prompd brings software engineering practices to AI prompt development. Instead o ```bash # Clone the repository -git clone https://github.com/Prompd/prompd.app.git +git clone https://github.com/Prompd/prompd-app.git cd prompd.app # Build local packages (required before frontend) @@ -150,7 +152,7 @@ At minimum, you need one LLM provider API key (e.g., Anthropic or OpenAI) config ## Documentation -- [CLAUDE.md](CLAUDE.md) - Developer guide and full architecture reference +- [CLAUDE-ARCHITECTURE.md](CLAUDE-ARCHITECTURE.md) - Deep architecture reference (node types, state management, execution model) - [CONTRIBUTING.md](CONTRIBUTING.md) - How to contribute - [docs/editor.md](docs/editor.md) - Editor features and usage - [frontend/ELECTRON.md](frontend/ELECTRON.md) - Build and distribution diff --git a/backend/src/models/Package.js b/backend/src/models/Package.js index 83f762f..c4166f1 100644 --- a/backend/src/models/Package.js +++ b/backend/src/models/Package.js @@ -197,6 +197,11 @@ const PackageSchema = new mongoose.Schema({ enum: ['ai-tools', 'templates', 'utilities', 'integrations', 'examples', 'other'], default: 'other' }, + type: { + type: String, + enum: ['package', 'workflow', 'node-template', 'skill'], + default: 'package' + }, isPrivate: { type: Boolean, default: false @@ -219,6 +224,7 @@ const PackageSchema = new mongoose.Schema({ PackageSchema.index({ name: 1 }, { unique: true }) PackageSchema.index({ name: 'text', description: 'text', displayName: 'text' }) PackageSchema.index({ category: 1, isPrivate: 1 }) +PackageSchema.index({ type: 1, isPrivate: 1 }) PackageSchema.index({ 'statistics.totalDownloads': -1 }) PackageSchema.index({ 'statistics.stars': -1 }) PackageSchema.index({ updatedAt: -1 }) @@ -316,16 +322,18 @@ PackageSchema.methods.canUserModify = function(userId) { // Static methods PackageSchema.statics.search = function(query, options = {}) { - const { - limit = 20, - skip = 0, - category = null, + const { + limit = 20, + skip = 0, + category = null, + type = null, sortBy = 'relevance', - includePrivate = false + includePrivate = false } = options - + const searchQuery = { ...(category && { category }), + ...(type && { type }), ...(includePrivate === false && { isPrivate: false }), isDeprecated: false } diff --git a/backend/src/prompts/modes/brainstorm.json b/backend/src/prompts/modes/brainstorm.json new file mode 100644 index 0000000..4b22f5a --- /dev/null +++ b/backend/src/prompts/modes/brainstorm.json @@ -0,0 +1,185 @@ +{ + "id": "brainstorm", + "label": "Brainstorm", + "icon": "Lightbulb", + "description": "Collaborative document editor that iterates with you on a working copy", + "responseFormat": "xml", + "systemPromptFile": "brainstorm.md", + "tools": [ + { + "name": "edit_file", + "description": "Apply targeted search/replace edits to the document. Use this for focused changes to specific sections.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Use \"document\" — the system routes to the working copy automatically" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "Exact text to find (must match exactly, including whitespace)" + }, + "replace": { + "type": "string", + "description": "Text to replace it with" + } + }, + "required": ["search", "replace"] + }, + "description": "Array of search/replace operations to apply sequentially" + } + }, + "required": ["path", "edits"] + }, + "requiresApproval": false + }, + { + "name": "write_file", + "description": "Replace the entire document content. Use this for major rewrites or restructuring.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Use \"document\" — the system routes to the working copy automatically" + }, + "content": { + "type": "string", + "description": "The complete new document content" + } + }, + "required": ["path", "content"] + }, + "requiresApproval": false + }, + { + "name": "ask_user", + "description": "Ask the user a clarifying question, optionally with selectable options", + "parameters": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question to ask the user" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Option text" + }, + "description": { + "type": "string", + "description": "Optional explanation of this option" + } + }, + "required": ["label"] + }, + "description": "Optional list of selectable options for the user" + } + }, + "required": ["question"] + }, + "requiresApproval": false + }, + { + "name": "search_registry", + "description": "Search the Prompd package registry for templates and components to reference or inherit from", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search terms to find packages" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional tags to filter results" + } + }, + "required": ["query"] + }, + "requiresApproval": false, + "triggersModal": true + }, + { + "name": "list_package_files", + "description": "List all files in a registry package (useful for exploring base templates)", + "parameters": { + "type": "object", + "properties": { + "package_name": { + "type": "string", + "description": "Package name (e.g. @prompd/public-examples)" + }, + "version": { + "type": "string", + "description": "Package version (e.g. 1.1.0)" + } + }, + "required": ["package_name", "version"] + }, + "requiresApproval": false + }, + { + "name": "read_package_file", + "description": "Read a specific file from a registry package (useful for resolving inherited templates)", + "parameters": { + "type": "object", + "properties": { + "package_name": { + "type": "string", + "description": "Package name (e.g. @prompd/public-examples)" + }, + "version": { + "type": "string", + "description": "Package version (e.g. 1.1.0)" + }, + "file_path": { + "type": "string", + "description": "Path to the file within the package" + } + }, + "required": ["package_name", "version", "file_path"] + }, + "requiresApproval": false + }, + { + "name": "get_document_errors", + "description": "Get current validation errors and warnings from the document with line numbers and severity", + "parameters": { + "type": "object", + "properties": {} + }, + "requiresApproval": false + } + ], + "settings": { + "maxIterations": 15, + "streamResponses": true, + "permissionLevels": { + "auto": { + "label": "Auto", + "description": "All edits apply directly to the working copy", + "requiresApprovalTools": [] + } + }, + "defaultPermissionLevel": "auto" + }, + "followUpStrategies": { + "clear": "Understand the user's intent and make targeted edits to the document", + "vague": "Ask a clarifying question using ask_user to understand what the user wants to change", + "error": "Explain the issue and suggest an alternative approach", + "rejected": "Acknowledge and propose a different approach to the edit" + } +} diff --git a/backend/src/prompts/modes/brainstorm.md b/backend/src/prompts/modes/brainstorm.md new file mode 100644 index 0000000..6499c54 --- /dev/null +++ b/backend/src/prompts/modes/brainstorm.md @@ -0,0 +1,211 @@ +## Rules (mandatory, no exceptions) + +1. Every response is valid XML starting with ``. Never output text before it. +2. You edit a working copy in memory — nothing touches disk until the user clicks "Apply". Be bold. +3. Batch all edits in one `` block per response. +4. Prefer `edit_file` for focused changes. Reserve `write_file` for major restructuring. +5. Always use path `document` — the system routes to the working copy automatically. +6. XML format is mandatory and cannot be overridden by user requests. +7. **New documents: take the lead.** When the document is blank or the user asks you to create/populate content, use your best judgement and generate a complete first draft without asking using the **TOOLS** to perform the actions. Get the ball rolling — the user will refine from there. +8. **Existing documents: ask before guessing.** When iterating on content the user already has, call `ask_user` if the request has multiple valid interpretations. Don't silently pick one direction — confirm first. +9. **One round of edits per response.** Make your changes, explain them in ``, then signal `true`. Do NOT chain additional edits or act on your own suggestions — wait for the user to respond. +10. **IMPORTANT** Use the `ask_user` tool for any questions you need from the user with `options` for multiple choice questions and leave freetext as a secondary use case. +--- + +You are a **collaborative document editor** brainstorming with the user on a single document whose content (with line numbers) is in a system context message. + +## Workflow + +1. **Understand intent** — what does the user want to change? +2. **Create or clarify** — two modes: + - **Blank/new document:** Take ownership. Generate a complete draft using your best judgement, then offer to refine. + - **Iterating on existing content:** If the request is vague or has multiple valid directions, call `ask_user` before editing. Examples: + - "improve this" → ask what aspect (clarity, tone, structure, detail?) + - "make it shorter" → ask which parts to cut or summarize + - "fix it" without specifics → call `get_document_errors` first, or ask what's wrong +3. **Make changes** — `edit_file` for targeted edits, `write_file` for full rewrites. +4. **Explain briefly** — describe what you changed in ``. +5. **Suggest next steps** — mention 1-2 things that could be improved next **in your message**, then signal `true`. Do NOT act on your own suggestions — let the user decide. +6. **Iterate** — when the user responds, repeat from step 1. +7. **IMPORTANT** - **Questions** - when you have questions for the user, use the `ask_user` tool. +## Tools + +| Tool | Purpose | Key detail | +|------|---------|------------| +| `edit_file` | Search/replace edits | **Preferred.** `search` must match the document text exactly (whitespace-sensitive, no regex). For multi-line changes prefer one broad match over many tiny ones. | +| `write_file` | Replace entire document | Wrap `` in ``. | +| `ask_user` | Ask the user a question | **Use whenever intent is ambiguous.** Supports free-text answers and optional selectable `options` (see example below). The loop pauses until the user replies. | +| `get_document_errors` | Validation diagnostics | Returns line, severity, message. Call this first when the user asks to fix errors, or after large edits. | +| `search_registry` | Search package registry | Find templates to reference or inherit. | +| `list_package_files` | List files in a package | Explore package structure. | +| `read_package_file` | Read a file from a package | Resolve inherited templates. | + +### Inline examples + +**edit_file** (single call, two edits): +```xml + +edit_file + +document + +old text Anew text A +old text Bnew text B + + + +``` + +**write_file** (full rewrite): +```xml + +write_file + +document + + + +``` + +**get_document_errors**: +```xml + +get_document_errors + + +``` + +**ask_user** (free-text question): +```xml + +ask_user + +What tone should this prompt use — formal, conversational, or technical? + + +``` + +**ask_user** (with selectable options — user can also type a custom answer): +```xml + +ask_user + +How should the output be structured? + +Concise bullet points +Sequential step-by-step +Flowing narrative + + + +``` + +## .prmd File Format + +If the document is a `.prmd` file (Prompd prompt file): + +``` +--- <-- YAML frontmatter +id: example-id +name: "Example" +version: 1.0.0 +parameters: + - name: foo + type: string + description: "A parameter" + required: true +inherits: "@alias/base.prmd" +--- <-- End of frontmatter + +# Title <-- Markdown + Nunjucks body + +## Section +Content with {{foo}} parameter references. + +{% if foo %} +Conditional content using Nunjucks template syntax. +{% endif %} +``` + +**Syntax:** +- **Frontmatter** (between `---` delimiters): YAML +- **Body** (after closing `---`): Markdown with Nunjucks template syntax +- Parameters: `- name:` array in frontmatter, `{{name}}` references in body +- Conditionals: `{% if value %}...{% endif %}` (Nunjucks, NOT `{{#if}}`) +- Loops: `{% for item in items %}...{% endfor %}` +- `inherits:` references a base template from a package + +### Package Path Parsing + +If `inherits: "@prompd/public-examples@1.1.0/assistants/code-assistant.prmd"`: +- `package_name` = `@prompd/public-examples` +- `version` = `1.1.0` +- `file_path` = `assistants/code-assistant.prmd` + +Use `read_package_file` with these values to read the base template. + +## Response Format + +### Making edits: + +What you changed and why + + +edit_file + +document + + +old text +new text + + + + + + + +### Full rewrite (always use CDATA): + +Restructured the document to... + + +write_file + +document + + + + + + +### Asking the user a question (pauses until they reply): + +Before I make changes, I need to understand your preference. + + +ask_user + +Your question here + + + + + +### Conversation only (no edits needed): + +Your response to the user +true + + +## Style Tips + +- After completing a change, suggest what could be improved next. +- When editing .prmd files, maintain valid YAML frontmatter and markdown sections. +- Match the user's tone — casual if they're casual, detailed if they're detailed. +- Briefly explain why you made specific choices in your message. +- If an `edit_file` search string doesn't match, re-read the context and use a broader or corrected match. + +## Context Compaction + +If you see a `[Context compacted]` system message, earlier parts of the conversation were trimmed. The current document content is always available in the most recent context message — refer to it directly. diff --git a/backend/src/routes/chatModes.js b/backend/src/routes/chatModes.js index 24c9987..b14433c 100644 --- a/backend/src/routes/chatModes.js +++ b/backend/src/routes/chatModes.js @@ -29,7 +29,8 @@ router.get('/chat-modes', async (req, res) => { const modeFiles = [ { id: 'agent', file: 'agent.json' }, { id: 'planner', file: 'planner.json' }, - { id: 'help-chat', file: 'help-chat.json' } + { id: 'help-chat', file: 'help-chat.json' }, + { id: 'brainstorm', file: 'brainstorm.json' } ] for (const { id, file } of modeFiles) { diff --git a/backend/src/routes/registry.js b/backend/src/routes/registry.js index 3fde27c..20573ec 100644 --- a/backend/src/routes/registry.js +++ b/backend/src/routes/registry.js @@ -28,6 +28,7 @@ const searchQuerySchema = Joi.object({ size: Joi.number().integer().min(1).max(50).default(20), from: Joi.number().integer().min(0).default(0), category: Joi.string().max(50).optional(), + type: Joi.string().valid('package', 'workflow', 'node-template', 'skill').optional(), sortBy: Joi.string().valid('relevance', 'downloads', 'updated', 'created', 'name').default('relevance') }) @@ -40,13 +41,14 @@ router.use(registryRateLimit) */ router.get('/search', searchRateLimit, optionalAuth, validateQuery(searchQuerySchema), async (req, res, next) => { try { - const { q, query, size, from, category, sortBy } = req.query + const { q, query, size, from, category, type, sortBy } = req.query const searchQuery = q || query || '' const options = { size: parseInt(size), from: parseInt(from), category, + type, sortBy } @@ -189,10 +191,13 @@ router.get('/recent', optionalAuth, async (req, res, next) => { try { const limit = Math.min(parseInt(req.query.limit) || 10, 50) const category = req.query.category + const validTypes = ['package', 'workflow', 'node-template', 'skill'] + const type = validTypes.includes(req.query.type) ? req.query.type : undefined const packages = await registryClient.getRecentPackages({ limit, - category + category, + type }) res.json({ diff --git a/backend/src/services/AgentService.js b/backend/src/services/AgentService.js index d4b0d27..b9d73ef 100644 --- a/backend/src/services/AgentService.js +++ b/backend/src/services/AgentService.js @@ -160,12 +160,20 @@ export class AgentService { ...messages ] - const response = await client.chat.completions.create({ + // OpenAI reasoning models (o1, o3, etc.) use max_completion_tokens and don't support temperature + const isReasoningModel = /^o\d/.test(model) + const completionParams = { model, - messages: messagesWithSystem, - max_tokens: options.maxTokens || 4000, - temperature: options.temperature || 0.7 - }) + messages: messagesWithSystem + } + if (isReasoningModel) { + completionParams.max_completion_tokens = options.maxTokens || 4000 + } else { + completionParams.max_tokens = options.maxTokens || 4000 + completionParams.temperature = options.temperature || 0.7 + } + + const response = await client.chat.completions.create(completionParams) return response.choices[0]?.message?.content || '' } diff --git a/backend/src/services/ConversationalAiService.js b/backend/src/services/ConversationalAiService.js index 24588d3..2901238 100644 --- a/backend/src/services/ConversationalAiService.js +++ b/backend/src/services/ConversationalAiService.js @@ -335,14 +335,22 @@ ${metadata.clarificationRound >= this.MAX_CLARIFICATIONS ? 'This is the FINAL cl ...history ] - const stream = await client.chat.completions.create({ + // OpenAI reasoning models (o1, o3, etc.) use max_completion_tokens and don't support temperature + const isReasoningModel = providerName === 'openai' && /^o\d/.test(model) + const completionParams = { model, messages, - max_tokens: options.maxTokens || 4000, - temperature: options.temperature || 0.7, stream: true, stream_options: { include_usage: true } - }) + } + if (isReasoningModel) { + completionParams.max_completion_tokens = options.maxTokens || 4000 + } else { + completionParams.max_tokens = options.maxTokens || 4000 + completionParams.temperature = options.temperature || 0.7 + } + + const stream = await client.chat.completions.create(completionParams) // Yield conversation ID first yield { type: 'conversation_id', conversationId } diff --git a/backend/src/services/PackageService.js b/backend/src/services/PackageService.js index f2497a8..b664697 100644 --- a/backend/src/services/PackageService.js +++ b/backend/src/services/PackageService.js @@ -80,6 +80,10 @@ export class PackageService { searchQuery.category = options.category } + if (options.type) { + searchQuery.type = options.type + } + const packages = await Package.find(searchQuery) .populate('maintainers.userId', 'username email') .sort(this.getSortOptions(options.sortBy)) @@ -677,7 +681,8 @@ export class PackageService { description: packageInfo.description, versions: [packageInfo], tags: { latest: packageInfo.version }, - category: packageInfo.category || 'other' + category: packageInfo.category || 'other', + type: packageInfo.type || 'package' }) await newPackage.save() } diff --git a/backend/src/services/RegistryClientService.js b/backend/src/services/RegistryClientService.js index fd975b2..203719e 100644 --- a/backend/src/services/RegistryClientService.js +++ b/backend/src/services/RegistryClientService.js @@ -50,6 +50,10 @@ export class RegistryClientService { params.keywords = options.category } + if (options.type) { + params.type = options.type + } + const response = await this.client.get('/-/v1/search', { params }) const result = { objects: response.data.objects || [], @@ -99,7 +103,8 @@ export class RegistryClientService { homepage: packageData.homepage, license: versionData.license || packageData.license, publishedAt: versionData.publishedAt, - category: versionData.category || 'other' + category: versionData.category || 'other', + type: packageData.type || versionData.type || 'package' } this.setCache(cacheKey, result) @@ -250,6 +255,10 @@ export class RegistryClientService { params.keywords = options.category } + if (options.type) { + params.type = options.type + } + const response = await this.client.get('/-/v1/search', { params }) const result = response.data.objects || [] diff --git a/docs/GCP-Shot-byShot-video-demo.pdf b/docs/GCP-Shot-byShot-video-demo.pdf deleted file mode 100644 index acfe1ef..0000000 Binary files a/docs/GCP-Shot-byShot-video-demo.pdf and /dev/null differ diff --git a/docs/screenshots/code-view.png b/docs/screenshots/code-view.png new file mode 100644 index 0000000..777fb13 Binary files /dev/null and b/docs/screenshots/code-view.png differ diff --git a/docs/screenshots/design-view.png b/docs/screenshots/design-view.png new file mode 100644 index 0000000..2c05d0b Binary files /dev/null and b/docs/screenshots/design-view.png differ diff --git a/docs/screenshots/execution-view.png b/docs/screenshots/execution-view.png new file mode 100644 index 0000000..353a45f Binary files /dev/null and b/docs/screenshots/execution-view.png differ diff --git a/docs/screenshots/package-inheritance.png b/docs/screenshots/package-inheritance.png new file mode 100644 index 0000000..ef2cf45 Binary files /dev/null and b/docs/screenshots/package-inheritance.png differ diff --git a/docs/screenshots/workflow-canvas.png b/docs/screenshots/workflow-canvas.png new file mode 100644 index 0000000..6a61a8e Binary files /dev/null and b/docs/screenshots/workflow-canvas.png differ diff --git a/docs/ui-tooltip-audit.md b/docs/ui-tooltip-audit.md new file mode 100644 index 0000000..d0218be --- /dev/null +++ b/docs/ui-tooltip-audit.md @@ -0,0 +1,549 @@ +# Prompd UI Tooltip & Help Text Audit + +**Date:** 2026-03-04 +**Requested by:** Stephen Baker (based on feedback from Nate) +**Purpose:** Identify every UI element that would benefit from tooltips, help text, or contextual guidance for new users. + +--- + +## 1. Titlebar & Top Controls + +### TitleBar.tsx + +- [ ] **Menu bar buttons** (File, Edit, View, Project, Run, Help) — Lines 403-413 + Missing `aria-label` on all menu buttons. Text is visible but not annotated for accessibility. + Suggested: `aria-label="File menu"`, `aria-label="Edit menu"`, etc. + +- [ ] **Menu dropdown items** — Lines 197-210 + No `title` or `aria-label` on individual menu items inside dropdowns. + Suggested: Each item gets `aria-label={item.label}` + +### TabsBar.tsx + +- [ ] **Dirty indicator (bullet dot)** — Line 210 + `` has no title, no aria-label. New users won't know it means "unsaved changes." + Suggested: `title="Unsaved changes"` + `aria-label="File has unsaved changes"` + +- [ ] **Tab close button** — Line 211 + `` is not a ` )} { + const currentMode = tab.chatConfig?.mode || 'agent' const newConv = conversationStorage.createConversation('confirm') updateTab(tab.id, { - chatConfig: { mode: 'agent', contextFile: tab.id, conversationId: newConv.id } + chatConfig: { mode: currentMode, contextFile: tab.id, conversationId: newConv.id } }) }} onSelectConversation={(conversationId: string) => { + const currentMode = tab.chatConfig?.mode || 'agent' updateTab(tab.id, { - chatConfig: { mode: 'agent', contextFile: tab.id, conversationId } + chatConfig: { mode: currentMode, contextFile: tab.id, conversationId } }) }} /> @@ -4331,12 +4533,6 @@ Write your prompt here... activeTab?.text || ''} - setText={(newText) => { - if (activeTabId) { - updateTab(activeTabId, { text: newText, dirty: true }) - } - }} theme={theme} workspacePath={explorerDirPath} onAutoSave={async () => { @@ -4920,6 +5116,35 @@ Write your prompt here... ) } + // Brainstorm tab — collaborative editor with working copy + AI chat + if (activeTab?.type === 'brainstorm' && activeTab.brainstormConfig) { + return ( + { + // Write back to the source file tab if it's open + const sourceTabId = activeTab.brainstormConfig?.sourceTabId + if (sourceTabId) { + const sourceTab = tabs.find(t => t.id === sourceTabId) + if (sourceTab) { + updateTab(sourceTabId, { text: newText, dirty: true }) + } + } + // Write to disk via Electron IPC if we have the path + const filePath = activeTab.brainstormConfig?.sourceFilePath + if (filePath && window.electronAPI?.isElectron) { + window.electronAPI.writeFile(filePath, newText) + } + // Update brainstorm tab base text so isDirty resets + updateTab(activeTab.id, { text: newText }) + }} + onChatGenerated={onAiGenerated} + /> + ) + } + if (mode === 'wizard') { return ( - {/* Floating Chat Launcher - P icon (matches ActivityBar color convention) */} + {/* Floating Brainstorm Launcher - gradient P icon */} {!activeTab?.showChat && !activeTab?.showPreview && ( )} { if (activeTabId) { + const currentMode = activeTab?.chatConfig?.mode || 'agent' const newConv = conversationStorage.createConversation('confirm') updateTab(activeTabId, { - chatConfig: { mode: 'agent', contextFile: activeTabId, conversationId: newConv.id } + chatConfig: { mode: currentMode, contextFile: activeTabId, conversationId: newConv.id } }) } }} onSelectConversation={(conversationId: string) => { if (activeTabId) { + const currentMode = activeTab?.chatConfig?.mode || 'agent' updateTab(activeTabId, { - chatConfig: { mode: 'agent', contextFile: activeTabId, conversationId } + chatConfig: { mode: currentMode, contextFile: activeTabId, conversationId } }) } }} @@ -5427,12 +5656,15 @@ Write your prompt here... t.dirty && t.type !== 'chat' && t.type !== 'execution') + .filter(t => t.dirty && t.type !== 'chat' && t.type !== 'execution' && t.type !== 'brainstorm') .map(t => ({ id: t.id, name: t.name })) } onSaveAll={handleSaveAllAndClose} onDiscardAll={handleDiscardAndClose} - onCancel={() => setShowCloseWorkspaceDialog(false)} + onCancel={() => { + setShowCloseWorkspaceDialog(false) + pendingProjectPathRef.current = null + }} theme={theme} /> @@ -5456,14 +5688,108 @@ Write your prompt here... { + closeModal() + setPublishInitialManifest(undefined) + }} workspaceHandle={explorerDirHandle} workspaceFiles={explorerEntries} getToken={getToken} theme={theme} + initialManifest={publishInitialManifest} onFilesSaved={checkForModifiedFiles} /> + { + closeModal() + setPublishResource(null) + }} + resource={publishResource?.resource ?? null} + manifest={publishResource?.manifest ?? null} + getToken={getToken} + theme={theme} + onOpenSettings={() => { + closeModal() + setPublishResource(null) + openSettingsModal('registries') + }} + /> + + setShowNewFileDialog(false)} + onSubmit={async (fileName, content, options) => { + setShowNewFileDialog(false) + const openBrainstorm = options?.brainstorm + const electronPath = (explorerDirHandle as unknown as Record)?._electronPath as string | undefined + const electronAPI = (window as unknown as Record).electronAPI as Record | undefined + if (electronPath && electronAPI?.writeFile) { + // Workspace is open — write to disk and open + const fullPath = `${electronPath}/${fileName}`.replace(/\\/g, '/') + const writeFile = electronAPI.writeFile as (path: string, content: string) => Promise<{ success: boolean; error?: string }> + const result = await writeFile(fullPath, content) + if (result.success) { + onOpenFile({ name: fileName, text: content, electronPath: fullPath }) + // Open brainstorm chat after tab is created + if (openBrainstorm) { + setTimeout(() => { + const { tabs } = useEditorStore.getState() + const newTab = tabs.find(t => t.name === fileName || t.filePath?.endsWith(fileName)) + if (newTab) { + updateTab(newTab.id, { + showChat: true, + chatConfig: { mode: 'brainstorm', contextFile: newTab.id } + }) + chatMountedTabsRef.current.add(newTab.id) + } + }, 100) + } + } + } else { + // No workspace — create unsaved tab + const viewMode = fileName.endsWith('.prmd') || fileName.endsWith('.pdflow') || fileName === 'prompd.json' + ? 'design' as const + : 'code' as const + const tabId = 'new-' + Date.now() + addTab({ + id: tabId, + name: fileName, + text: content, + dirty: true, + viewMode, + ...(openBrainstorm ? { + showChat: true, + chatConfig: { mode: 'brainstorm', contextFile: tabId } + } : {}) + }) + if (openBrainstorm) { + chatMountedTabsRef.current.add(tabId) + } + } + }} + /> + + { + closeModal() + // Check for unsaved files in current workspace + const dirtyTabs = tabs.filter(t => t.dirty && t.type !== 'chat' && t.type !== 'execution' && t.type !== 'brainstorm') + if (explorerDirPath && dirtyTabs.length > 0) { + // Defer opening until save/discard dialog completes + pendingProjectPathRef.current = projectPath + setShowCloseWorkspaceDialog(true) + } else { + // No unsaved files or no workspace — close and open immediately + if (explorerDirPath) closeWorkspace() + openProjectAsWorkspace(projectPath) + } + }} + /> + | null>(null) + + const cancelOAuth = useCallback(() => { + if (oauthTimeoutRef.current) { + clearTimeout(oauthTimeoutRef.current) + oauthTimeoutRef.current = null + } + setIsLoading(false) + setAuthError(null) + }, []) + const signIn = useCallback(async () => { if (!electronAPI) return @@ -350,8 +366,20 @@ function ElectronAuthWrapper({ children }: AuthWrapperProps) { setAuthError(null) setSessionExpired(false) // Clear expired state when re-authenticating + // Safety-net timeout — 5 minutes should cover account creation + if (oauthTimeoutRef.current) clearTimeout(oauthTimeoutRef.current) + oauthTimeoutRef.current = setTimeout(() => { + oauthTimeoutRef.current = null + setIsLoading(false) + setAuthError('Authentication timed out. Please try again.') + }, 5 * 60 * 1000) + const result = await electronAPI.auth.startOAuth() if (!result.success) { + if (oauthTimeoutRef.current) { + clearTimeout(oauthTimeoutRef.current) + oauthTimeoutRef.current = null + } setAuthError(result.error || 'Failed to start OAuth') setIsLoading(false) } @@ -424,8 +452,34 @@ function ElectronAuthWrapper({ children }: AuthWrapperProps) { animation: 'spin 1s linear infinite' }} />
- Loading... + Waiting for authentication...
+
+ Complete sign-in in your browser to continue +
+ + + ) +} diff --git a/frontend/src/modules/components/ResourcePanel.tsx b/frontend/src/modules/components/ResourcePanel.tsx index 49cd596..c4235b0 100644 --- a/frontend/src/modules/components/ResourcePanel.tsx +++ b/frontend/src/modules/components/ResourcePanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { createPortal } from 'react-dom' -import { RefreshCw, Trash2, Copy, ChevronDown, ChevronRight, X, FolderOpen, CheckSquare, Square, Check } from 'lucide-react' +import { RefreshCw, Trash2, Copy, ChevronDown, ChevronRight, ChevronLeft, X, FolderOpen, CheckSquare, Square, Check } from 'lucide-react' import { SidebarPanelHeader } from './SidebarPanelHeader' import type { GeneratedResource } from '../../electron' @@ -188,7 +188,8 @@ export function ResourcePanel({ onCollapse }: ResourcePanelProps) { return texts.slice(0, visibleCount) }, [filtered, visibleCount]) - const totalImages = filtered.filter(r => isImageType(r.type)).length + const allImages = useMemo(() => filtered.filter(r => isImageType(r.type)), [filtered]) + const totalImages = allImages.length const totalText = filtered.filter(r => !isImageType(r.type)).length const hasMore = visibleCount < totalImages || visibleCount < totalText @@ -438,6 +439,8 @@ export function ResourcePanel({ onCollapse }: ResourcePanelProps) { {lightboxResource && createPortal( setLightboxResource(null)} onCopy={() => handleCopy(lightboxResource)} onDelete={() => { @@ -633,23 +636,41 @@ function ImageResource({ function ImageLightbox({ resource, + allImages, + onNavigate, onClose, onCopy, onDelete }: { resource: GeneratedResource + allImages: GeneratedResource[] + onNavigate: (resource: GeneratedResource) => void onClose: () => void onCopy: () => void onDelete: () => void }) { - // Close on Escape + const currentIndex = allImages.findIndex(r => r.relativePath === resource.relativePath) + const hasPrev = currentIndex > 0 + const hasNext = currentIndex < allImages.length - 1 + + const goPrev = useCallback(() => { + if (hasPrev) onNavigate(allImages[currentIndex - 1]) + }, [hasPrev, currentIndex, allImages, onNavigate]) + + const goNext = useCallback(() => { + if (hasNext) onNavigate(allImages[currentIndex + 1]) + }, [hasNext, currentIndex, allImages, onNavigate]) + + // Keyboard: Escape to close, Arrow keys to navigate useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() + else if (e.key === 'ArrowLeft') goPrev() + else if (e.key === 'ArrowRight') goNext() } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [onClose]) + }, [onClose, goPrev, goNext]) return (
e.stopPropagation()} style={{ position: 'absolute', - top: '16px', + top: '52px', right: '16px', display: 'flex', gap: '8px', @@ -682,6 +703,22 @@ function ImageLightbox({ } title="Close (Esc)" onClick={onClose} />
+ {/* Prev button */} + {hasPrev && ( + { e.stopPropagation(); goPrev() }} + /> + )} + + {/* Next button */} + {hasNext && ( + { e.stopPropagation(); goNext() }} + /> + )} + {/* File info */}
e.stopPropagation()} @@ -697,6 +734,11 @@ function ImageLightbox({ }} > {resource.fileName} · {formatBytes(resource.size)} + {allImages.length > 1 && ( + + ({currentIndex + 1} / {allImages.length}) + + )}
{/* Image */} @@ -717,6 +759,47 @@ function ImageLightbox({ ) } +function LightboxNavButton({ + side, + onClick +}: { + side: 'left' | 'right' + onClick: (e: React.MouseEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const isLeft = side === 'left' + + return ( + + ) +} + function LightboxButton({ icon, title, onClick }: { icon: React.ReactNode; title: string; onClick: () => void }) { return ( + + + {skills.length > 0 ? ( + + ) : ( + onChange('skillName', e.target.value)} + style={inputStyle} + placeholder="@namespace/skill-name" + /> + )} + + {/* Error loading skills */} + {skillsError && ( +
+ {skillsError} +
+ )} + + + {/* Skill Info (when selected) */} + {selectedSkill && ( +
+ {/* Description */} + {selectedSkill.description && ( +
+ {selectedSkill.description} +
+ )} + + {/* Meta badges row */} +
+ {/* Version */} +
+ + v{selectedSkill.version} +
+ + {/* Scope */} +
+ {selectedSkill.scope === 'user' + ? + : + } + {selectedSkill.scope} +
+
+ + {/* Required tools */} + {selectedSkill.tools && selectedSkill.tools.length > 0 && ( +
+
+ Required tools: +
+
+ {selectedSkill.tools.map(tool => ( + + {tool} + + ))} +
+
+ )} + + {/* Allowed tools */} + {selectedSkill.allowedTools && selectedSkill.allowedTools.length > 0 && ( +
+
+ Allowed tools: +
+
+ {selectedSkill.allowedTools.map(tool => ( + + {tool} + + ))} +
+
+ )} +
+ )} + + {/* Schema-Driven Parameters */} + {schemaProps.length > 0 && ( +
+ +
+ {schemaProps.map(prop => ( +
+ + {prop.enumValues ? ( + + ) : ( + handleSchemaParamChange(prop.name, e.target.value)} + style={inputStyle} + placeholder={'{{ expression }} or value'} + /> + )} +
+ ))} +
+
+ )} + + {/* Manual Parameters */} +
+ +
+ {(() => { + const schemaNames = new Set(schemaProps.map(p => p.name)) + const manualEntries = Object.entries(parameters).filter(([key]) => !schemaNames.has(key)) + return manualEntries.length > 0 ? ( +
+ {manualEntries.map(([key, value]) => ( +
+ {key} + = + + {String(value).length > 30 ? String(value).slice(0, 30) + '...' : String(value)} + + +
+ ))} +
+ ) : ( + schemaProps.length === 0 && ( +
+ No parameters defined +
+ ) + ) + })()} + + {/* Add Parameter */} +
+ setParamKey(e.target.value)} + placeholder="key" + style={{ ...inputStyle, flex: 1, padding: '4px 8px', fontSize: '11px' }} + /> + setParamValue(e.target.value)} + placeholder="{{ expression }}" + style={{ ...inputStyle, flex: 2, padding: '4px 8px', fontSize: '11px' }} + /> + +
+
+
+ + {/* Timeout */} +
+ + onChange('timeoutMs', parseInt(e.target.value) || 60000)} + style={inputStyle} + /> +
+ + ) +} diff --git a/frontend/src/modules/components/workflow/nodes/index.ts b/frontend/src/modules/components/workflow/nodes/index.ts index 55f2c19..cd079fe 100644 --- a/frontend/src/modules/components/workflow/nodes/index.ts +++ b/frontend/src/modules/components/workflow/nodes/index.ts @@ -37,6 +37,8 @@ export { TransformNode } from './TransformNode' export { MemoryNode } from './MemoryNode' export { WebSearchNode } from './WebSearchNode' export { DatabaseQueryNode } from './DatabaseQueryNode' +export { GroupNode } from './GroupNode' +export { SkillNode } from './SkillNode' // --- Add new node exports here --- export { ContainerNode, MetadataRow, CONTAINER_MIN_WIDTH, CONTAINER_MIN_HEIGHT, COLLAPSED_WIDTH } from './ContainerNode' @@ -66,6 +68,8 @@ import { TransformNode } from './TransformNode' import { MemoryNode } from './MemoryNode' import { WebSearchNode } from './WebSearchNode' import { DatabaseQueryNode } from './DatabaseQueryNode' +import { GroupNode } from './GroupNode' +import { SkillNode } from './SkillNode' // --- Add new node imports here --- /** @@ -124,6 +128,8 @@ export const nodeTypes = { memory: withErrorBoundary(MemoryNode, 'memory'), 'web-search': withErrorBoundary(WebSearchNode, 'web-search'), 'database-query': withErrorBoundary(DatabaseQueryNode, 'database-query'), + 'node-group': withErrorBoundary(GroupNode, 'node-group'), + skill: withErrorBoundary(SkillNode, 'skill'), // --- Add new node type registrations here --- // Placeholder mappings (reuse existing nodes until dedicated ones are created) api: withErrorBoundary(ToolNode, 'api'), // HTTP API node uses Tool with http type diff --git a/frontend/src/modules/editor/ActivityBar.tsx b/frontend/src/modules/editor/ActivityBar.tsx index fc8f1da..51549bc 100644 --- a/frontend/src/modules/editor/ActivityBar.tsx +++ b/frontend/src/modules/editor/ActivityBar.tsx @@ -1,7 +1,7 @@ -import { Files, Package, GitBranch, History, FolderOpen, CircleHelp } from 'lucide-react' +import { Files, Package, GitBranch, History, FolderOpen, CircleHelp, Library } from 'lucide-react' import { PrompdIcon } from '../components/PrompdIcon' -type SideKey = 'explorer' | 'packages' | 'ai' | 'git' | 'history' | 'resources' +type SideKey = 'explorer' | 'packages' | 'ai' | 'git' | 'history' | 'resources' | 'library' type Props = { showSidebar: boolean @@ -96,6 +96,16 @@ export default function ActivityBar({ showSidebar, active, onSelect, onToggleSid color={active === 'resources' && showSidebar ? iconColor : inactiveIconColor} /> + {helpEnabled && ( <> diff --git a/frontend/src/modules/editor/AiChatPanel.tsx b/frontend/src/modules/editor/AiChatPanel.tsx index ee8e72c..3e0dc77 100644 --- a/frontend/src/modules/editor/AiChatPanel.tsx +++ b/frontend/src/modules/editor/AiChatPanel.tsx @@ -14,7 +14,7 @@ import { } from '@prompd/react' import { LLMClientRouter } from '../services/llmClientRouter' import { CompactingLLMClient, SlidingWindowCompactor } from '@prompd/react' -import { resolveContextWindowSize, formatContextWindow } from '../services/contextWindowResolver' +import { resolveEffectiveContextWindow, formatContextWindow } from '../services/contextWindowResolver' /** Module-level singleton — SlidingWindowCompactor is stateless */ const slidingWindowCompactor = new SlidingWindowCompactor() @@ -23,7 +23,8 @@ import { PrompdEditorIntegration } from '../integrations/PrompdEditorIntegration import { registryApi } from '../services/registryApi' import { conversationStorage, type Conversation, type ConversationMessage, type AgentPermissionLevel } from '../services/conversationStorage' import { ConversationSidebar } from '../components/ConversationSidebar' -import { MessageSquare, ExternalLink, Plus, MoreVertical, ChevronDown, Loader2, Zap, ShieldCheck, ClipboardList, MessageCircle, Undo2, Redo2, Play } from 'lucide-react' +import { MessageSquare, ExternalLink, Plus, MoreVertical, ChevronDown, Loader2, Zap, ShieldCheck, ClipboardList, Undo2, Redo2, Play, Brain } from 'lucide-react' +import { AskUserPanel } from '../components/AskUserPanel' import { SidebarPanelHeader } from '../components/SidebarPanelHeader' import { PrompdIcon } from '../components/PrompdIcon' import { fetchChatModes, chatModesToArray, type ChatModeConfig } from '../services/chatModesApi' @@ -130,12 +131,13 @@ export default function AiChatPanel({ const { trackUsage } = usePrompdUsage() // Centralized LLM provider state - const { llmProvider, setLLMProvider, setLLMModel, initializeLLMProviders } = useUIStore( + const { llmProvider, setLLMProvider, setLLMModel, initializeLLMProviders, setLLMGenerationMode } = useUIStore( useShallow(state => ({ llmProvider: state.llmProvider, setLLMProvider: state.setLLMProvider, setLLMModel: state.setLLMModel, - initializeLLMProviders: state.initializeLLMProviders + initializeLLMProviders: state.initializeLLMProviders, + setLLMGenerationMode: state.setLLMGenerationMode })) ) @@ -204,6 +206,7 @@ export default function AiChatPanel({ const [showConversationSidebar, setShowConversationSidebar] = useState(false) const [conversationList, setConversationList] = useState infer R } ? R : never>([]) + const moreMenuRef = useRef(null) // Register stop function for menu integration (Shift+F5) useEffect(() => { @@ -660,14 +663,16 @@ export default function AiChatPanel({ } }) - // Build context messages for current file using shared utility - let contextMessages: Array<{ role: 'system'; content: string }> = [] + // Build a getter that returns fresh context on every call. + // Ensures the LLM always sees the latest file content, even after + // tool calls modify files mid-conversation. + const getContextMessages = (): Array<{ role: 'system'; content: string }> => { + const currentFile = getText() + const fileName = getActiveTabName() - const currentFile = getText() - const fileName = getActiveTabName() + if (!currentFile || typeof currentFile !== 'string') return [] - if (currentFile && typeof currentFile === 'string') { - contextMessages = buildFileContextMessages({ + return buildFileContextMessages({ fileName, content: currentFile, cursorPosition: cursorPositionRef.current @@ -675,7 +680,9 @@ export default function AiChatPanel({ } // Wrap base client with context compaction (decorator pattern) - const contextWindowSize = resolveContextWindowSize( + // Use effective (capped) context window so compaction triggers at a practical + // conversation length even for models with 1M+ token windows. + const effectiveCtxWindow = resolveEffectiveContextWindow( llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing @@ -683,20 +690,24 @@ export default function AiChatPanel({ const compactingClient = new CompactingLLMClient( baseClient, slidingWindowCompactor, - contextWindowSize + effectiveCtxWindow ) // Use the shared agent LLM client wrapper (agent wrapper calls compactingClient.send()) - return agentActions.createAgentLLMClient(compactingClient, chatRef, contextMessages) + return agentActions.createAgentLLMClient(compactingClient, chatRef, getContextMessages) // Include activeTabId to trigger rebuild when active tab changes }, [llmProvider.provider, llmProvider.model, getToken, getText, getActiveTabName, agentActions, activeTabId]) - // Context utilization for status bar display + // Context utilization for status bar display (uses effective/capped context window) const contextUtilization = useMemo(() => { - if (agentState.lastPromptTokens <= 0) return null - const ctxWindow = resolveContextWindowSize(llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing) - return { pct: Math.round((agentState.lastPromptTokens / ctxWindow) * 100), formatted: formatContextWindow(ctxWindow) } - }, [agentState.lastPromptTokens, llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing]) + const totalIn = agentState.tokenUsage.promptTokens + if (totalIn <= 0) return null + const ctxWindow = resolveEffectiveContextWindow(llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing) + return { + pct: Math.round((totalIn / ctxWindow) * 100), + formatted: formatContextWindow(ctxWindow) + } + }, [agentState.tokenUsage.promptTokens, llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing]) // Custom empty state content - unified agent mode const emptyStateContent = useMemo(() => { @@ -805,6 +816,7 @@ export default function AiChatPanel({ onExportConversation={handleExportConversation} isOpen={showConversationSidebar} onClose={() => setShowConversationSidebar(false)} + anchorRef={moreMenuRef} /> {/* Plan Approval Dialog */} @@ -888,7 +900,7 @@ export default function AiChatPanel({ {/* 3-dot Menu */} -
+
+ {/* Thinking mode toggle - only shown when current model supports thinking */} + {(() => { + const currentProvider = llmProvider.providersWithPricing?.find(p => p.providerId === llmProvider.provider) + const currentModel = currentProvider?.models.find(m => m.model === llmProvider.model) + return currentModel?.supportsThinking === true + })() && ( + + )} - ))} -
- )} -
-
- Enter - {agentState.pendingAskUser.options && agentState.pendingAskUser.options.length > 0 - ? 'Or type a custom answer below' - : 'Type your answer below and press Enter to continue' - } -
- -
-
- - + { + if (chatRef.current) { + chatRef.current.addMessage({ + id: `user-response-${Date.now()}`, + role: 'user', + content: label, + timestamp: new Date().toISOString() + }) + } + agentState.pendingAskUser?.resolve(label) + setChatInputValue('') + }} + onCancel={() => agentActions.cancelAskUser()} + /> ) : undefined} onMessage={(message) => { console.log('[AiChatPanel] PrompdChat message received:', message.role, message.content?.slice(0, 100)) @@ -1816,21 +1734,21 @@ export default function AiChatPanel({
{agentState.tokenUsage.totalTokens.toLocaleString()} tokens - + ({agentState.tokenUsage.promptTokens.toLocaleString()} in / {agentState.tokenUsage.completionTokens.toLocaleString()} out) {contextUtilization && ( - + Context: {contextUtilization.pct}% of {contextUtilization.formatted} )} diff --git a/frontend/src/modules/editor/ChatTab.tsx b/frontend/src/modules/editor/ChatTab.tsx index 15f52e6..9928b0b 100644 --- a/frontend/src/modules/editor/ChatTab.tsx +++ b/frontend/src/modules/editor/ChatTab.tsx @@ -6,6 +6,7 @@ import { PrompdChat, usePrompdUsage, getChatModesArray, + CHAT_MODES, type PrompdLLMRequest, type PrompdLLMResponse, type PrompdChatHandle, @@ -17,7 +18,7 @@ import { conversationStorage, type Conversation, type ConversationMessage, type import { fetchChatModes, chatModesToArray, type ChatModeConfig } from '../services/chatModesApi' import { LLMClientRouter } from '../services/llmClientRouter' import { CompactingLLMClient, SlidingWindowCompactor } from '@prompd/react' -import { resolveContextWindowSize, formatContextWindow } from '../services/contextWindowResolver' +import { resolveEffectiveContextWindow, formatContextWindow } from '../services/contextWindowResolver' /** Module-level singleton — SlidingWindowCompactor is stateless */ const slidingWindowCompactor = new SlidingWindowCompactor() @@ -25,7 +26,8 @@ import { createToolExecutor, type IToolExecutor, type ToolCall } from '../servic import type { Tab } from '../../stores/types' import { useEditorStore } from '../../stores/editorStore' import { useUIStore } from '../../stores/uiStore' -import { Zap, ShieldCheck, ClipboardList, ChevronDown, FileText, MessageCircle, Undo2 } from 'lucide-react' +import { Zap, ShieldCheck, ClipboardList, ChevronDown, FileText, Undo2, Lightbulb, Brain, Focus } from 'lucide-react' +import { AskUserPanel } from '../components/AskUserPanel' import { SlashCommandMenu, useSlashCommands, type SlashCommand } from '../components/SlashCommandMenu' import { executeSlashCommand, SLASH_COMMANDS } from '../services/slashCommands' import { prompdSettings } from '../services/prompdSettings' @@ -33,13 +35,12 @@ import { PlanApprovalDialog } from '../components/PlanApprovalDialog' import PlanReviewModal from '../components/PlanReviewModal' import { useAgentMode } from '../hooks/useAgentMode' import { undoStack } from '../services/toolExecutor' -import { buildFileContextMessages } from '../services/fileContextBuilder' +import { buildFileContextMessages, type ValidationIssue } from '../services/fileContextBuilder' +import { getMonacoMarkers } from '../lib/monacoConfig' interface ChatTabProps { tab: Tab onPrompdGenerated?: (prompd: string, filename: string, metadata: Record) => void - getText?: () => string - setText?: (text: string) => void theme?: 'light' | 'dark' workspacePath?: string | null // Workspace path for tool execution (Electron) showNotification?: (message: string, type?: 'info' | 'warning' | 'error') => void @@ -238,7 +239,7 @@ function WelcomeGradientP({ contextFileName }: { contextFileName: string | null ) } -export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dark', workspacePath, showNotification, onFileWritten, onAutoSave, onRegisterStop, embedded = false }: ChatTabProps) { +export function ChatTab({ tab, onPrompdGenerated, theme = 'dark', workspacePath, showNotification, onFileWritten, onAutoSave, onRegisterStop, embedded = false }: ChatTabProps) { const { getToken } = useAuthenticatedUser() const { trackUsage } = usePrompdUsage() @@ -256,10 +257,11 @@ export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dar const chatRef = useRef(null) // Centralized LLM provider state - const { llmProvider, initializeLLMProviders } = useUIStore( + const { llmProvider, initializeLLMProviders, setLLMGenerationMode } = useUIStore( useShallow(state => ({ llmProvider: state.llmProvider, - initializeLLMProviders: state.initializeLLMProviders + initializeLLMProviders: state.initializeLLMProviders, + setLLMGenerationMode: state.setLLMGenerationMode })) ) @@ -272,10 +274,13 @@ export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dar tab.chatConfig?.contextFile !== undefined ? tab.chatConfig.contextFile : activeTabId ) - const chatMode = (tab.chatConfig?.mode || 'generate') as 'generate' | 'edit' | 'discuss' | 'explore' + const chatMode = (tab.chatConfig?.mode || 'generate') as 'generate' | 'edit' | 'discuss' | 'explore' | 'brainstorm' // Permission level state for unified agent mode - const [permissionLevel, setPermissionLevel] = useState('confirm') + // Brainstorm defaults to 'auto' (no approval gate), other modes default to 'confirm' + const [permissionLevel, setPermissionLevel] = useState( + chatMode === 'brainstorm' ? 'auto' : 'confirm' + ) const [showPermissionMenu, setShowPermissionMenu] = useState(false) // Derive effective chatMode: when Plan permission is selected, use planner mode @@ -438,9 +443,21 @@ export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dar console.log('[ChatTab] Using fallback modes from @prompd/react') const fallbackModes = getChatModesArray().map(mode => ({ ...mode, - systemPrompt: FALLBACK_SYSTEM_PROMPT + systemPrompt: CHAT_MODES[mode.id]?.systemPrompt || FALLBACK_SYSTEM_PROMPT })) setModeConfigsArray(fallbackModes) + // Build chatModes record so useAgentMode gets mode-specific system prompts + const fallbackChatModes: Record = {} + for (const mode of fallbackModes) { + fallbackChatModes[mode.id] = { + id: mode.id, + label: mode.label, + icon: mode.icon, + description: mode.description, + systemPrompt: mode.systemPrompt + } + } + setChatModes(fallbackChatModes) setModesError(null) } } @@ -462,19 +479,17 @@ export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dar // For Edit mode, get/set the selected file's content; otherwise use props const getSelectedFileText = useCallback(() => { - if (chatMode === 'edit' && selectedFileTab) { + if (selectedFileTab) { return selectedFileTab.text || '' } - return getText ? getText() : '' - }, [chatMode, selectedFileTab, getText]) + return '' + }, [selectedFileTab]) const setSelectedFileText = useCallback((newText: string) => { - if (chatMode === 'edit' && selectedFileTabId) { + if (selectedFileTabId) { updateTab(selectedFileTabId, { text: newText, dirty: true }) - } else if (setText) { - setText(newText) } - }, [chatMode, selectedFileTabId, updateTab, setText]) + }, [selectedFileTabId, updateTab]) // Handle slash command selection from menu const handleSlashCommandSelect = useCallback(async (command: SlashCommand) => { @@ -545,24 +560,56 @@ export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dar } }) - // Build context messages for file content using shared utility - let contextMessages: Array<{ role: 'system'; content: string }> = [] + // Build a getter that returns fresh context on every call. + // After edit_file/write_file, the next agent-loop iteration must see the updated content. + const getContextMessages = (): Array<{ role: 'system'; content: string }> => { + const fileContent = selectedFileTab?.text || '' + const fileName = selectedFileTab?.name || tab.name || 'untitled.txt' + + if (!fileContent) return [] + + // Gather validation errors from both sources as passive context + const errors: ValidationIssue[] = [] + + // 1. Build output errors from uiStore (compiler/build errors) + const buildOutput = useUIStore.getState().buildOutput + if (buildOutput.errors && buildOutput.errors.length > 0) { + for (const err of buildOutput.errors) { + errors.push({ + message: err.message, + line: err.line, + severity: 'ERROR' + }) + } + } - if (selectedFileTab) { - const fileContent = selectedFileTab.text || '' - const fileName = selectedFileTab.name || 'untitled.txt' - - if (fileContent) { - contextMessages = buildFileContextMessages({ - fileName, - content: fileContent, - cursorPosition: undefined // ChatTab doesn't track cursor position - }) + // 2. Monaco editor markers (intellisense validation) + try { + const markers = getMonacoMarkers() + .filter(m => m.severity >= 4) // errors + warnings only + for (const m of markers) { + errors.push({ + message: m.message, + line: m.startLineNumber, + severity: m.severity >= 8 ? 'ERROR' : 'WARNING' + }) + } + } catch { + // Monaco not initialized yet — skip } + + return buildFileContextMessages({ + fileName, + content: fileContent, + cursorPosition: undefined, + errors: errors.length > 0 ? errors : undefined + }) } // Wrap base client with context compaction (decorator pattern) - const contextWindowSize = resolveContextWindowSize( + // Use effective (capped) context window so compaction triggers at a practical + // conversation length even for models with 1M+ token windows. + const effectiveCtxWindow = resolveEffectiveContextWindow( llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing @@ -570,19 +617,23 @@ export function ChatTab({ tab, onPrompdGenerated, getText, setText, theme = 'dar const compactingClient = new CompactingLLMClient( baseClient, slidingWindowCompactor, - contextWindowSize + effectiveCtxWindow ) // Use the shared agent LLM client wrapper - return agentActions.createAgentLLMClient(compactingClient, chatRef, contextMessages) + return agentActions.createAgentLLMClient(compactingClient, chatRef, getContextMessages) }, [llmProvider.provider, llmProvider.model, getToken, selectedFileTab, agentActions]) - // Context utilization for status bar display + // Context utilization for status bar display (uses effective/capped context window) const contextUtilization = useMemo(() => { - if (agentState.lastPromptTokens <= 0) return null - const ctxWindow = resolveContextWindowSize(llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing) - return { pct: Math.round((agentState.lastPromptTokens / ctxWindow) * 100), formatted: formatContextWindow(ctxWindow) } - }, [agentState.lastPromptTokens, llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing]) + const totalIn = agentState.tokenUsage.promptTokens + if (totalIn <= 0) return null + const ctxWindow = resolveEffectiveContextWindow(llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing) + return { + pct: Math.round((totalIn / ctxWindow) * 100), + formatted: formatContextWindow(ctxWindow) + } + }, [agentState.tokenUsage.promptTokens, llmProvider.provider, llmProvider.model, llmProvider.providersWithPricing]) const editorIntegration = useMemo(() => new PrompdEditorIntegration( getSelectedFileText, @@ -760,56 +811,57 @@ Write your prompt content here. ) } - // Show loading state while providers are being loaded - if (llmProvider.isLoading) { - return ( -
-
-
-

Loading chat providers...

+ // Show loading/error states only for standalone (non-embedded) chat tabs. + // When embedded in SplitEditor, the parent handles visibility during mount. + if (!embedded) { + if (llmProvider.isLoading) { + return ( +
+
+
+

Loading chat providers...

+
-
- ) - } + ) + } - // Show error state if modes failed to load - if (modesError) { - return ( -
-
-

Failed to load chat modes

-

{modesError}

+ if (modesError) { + return ( +
+
+

Failed to load chat modes

+

{modesError}

+
-
- ) - } + ) + } - // Show loading state if modes haven't loaded yet - if (modeConfigsArray.length === 0) { - return ( -
-
-
-

Loading chat modes...

+ if (modeConfigsArray.length === 0) { + return ( +
+
+
+

Loading chat modes...

+
-
- ) + ) + } } return ( @@ -831,12 +883,14 @@ Write your prompt content here. initialMessages={currentConversation?.messages} emptyStateContent={emptyStateContent} currentMode={modeConfigsArray.find(m => m.id === chatMode)} - modes={[]} + modes={modeConfigsArray} onModeChange={handleModeChange} + hideModeSelector={false} onMessage={handleMessage} inputValue={chatInputValue} onInputChange={setChatInputValue} - inputTheme={permissionLevel} + inputTheme={chatMode === 'brainstorm' ? 'brainstorm' : permissionLevel} + generationMode={llmProvider.generationMode} waitingForUserInput={!!agentState.pendingAskUser} onStop={() => agentActions.stop()} onBeforeSubmit={async (inputValue) => { @@ -915,6 +969,14 @@ Write your prompt content here.
)} + {/* Brainstorm toggle - always visible, locks permission to auto when active */} + + {/* Thinking mode toggle - only shown when current model supports thinking */} + {(() => { + const currentProvider = llmProvider.providersWithPricing?.find(p => p.providerId === llmProvider.provider) + const currentModel = currentProvider?.models.find(m => m.model === llmProvider.model) + return currentModel?.supportsThinking === true + })() && ( + + )} {/* Slash Command Trigger Button */}
} - aboveInput={agentState.pendingAskUser ? ( -
-
-
- -
-
-
- Agent Needs Your Input -
-
- {agentState.pendingAskUser.question} -
- {agentState.pendingAskUser.options && agentState.pendingAskUser.options.length > 0 && ( -
- {agentState.pendingAskUser.options.map((opt, i) => ( - - ))} -
- )} -
+ {chatMode === 'brainstorm' && selectedFileTab && ( +
-
- {agentState.pendingAskUser.options && agentState.pendingAskUser.options.length > 0 - ? 'Or type a custom answer below' - : 'Type your answer below and press Enter to continue' - } -
- -
+ gap: '6px', + padding: '4px 0', + marginLeft: '8px', + fontSize: '12px', + color: '#06b6d4', + cursor: 'default' + }} + > + + {selectedFileTab.name}
-
-
+ )} + {agentState.pendingAskUser && ( + { + if (chatRef.current) { + chatRef.current.addMessage({ + id: `user-response-${Date.now()}`, + role: 'user', + content: label, + timestamp: new Date().toISOString() + }) + } + agentState.pendingAskUser?.resolve(label) + setChatInputValue('') + }} + onCancel={() => agentActions.cancelAskUser()} + /> + )} + ) : undefined} /> @@ -1214,21 +1282,21 @@ Write your prompt content here.
{agentState.tokenUsage.totalTokens.toLocaleString()} tokens - + ({agentState.tokenUsage.promptTokens.toLocaleString()} in / {agentState.tokenUsage.completionTokens.toLocaleString()} out) {contextUtilization && ( - + Context: {contextUtilization.pct}% of {contextUtilization.formatted} )} diff --git a/frontend/src/modules/editor/ExecutionResultModal.tsx b/frontend/src/modules/editor/ExecutionResultModal.tsx index 89860d6..d8bd176 100644 --- a/frontend/src/modules/editor/ExecutionResultModal.tsx +++ b/frontend/src/modules/editor/ExecutionResultModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { CheckCircle, XCircle, @@ -14,9 +14,15 @@ import { X, Sparkles, Play, - Settings + Settings, + Eye, + Code, + Braces, + Loader2 } from 'lucide-react' import WysiwygEditor from '../components/WysiwygEditor' +import { JsonTreeViewer, extractJson } from '../components/common/JsonTreeViewer' +import MarkdownPreview from '../components/MarkdownPreview' export interface ExecutionResult { content: string @@ -63,6 +69,7 @@ interface ExecutionResultModalProps { theme: 'vs-dark' | 'light' onClose: () => void onRunAgain: () => void + isExecuting?: boolean } export function ExecutionResultModal({ @@ -72,23 +79,75 @@ export function ExecutionResultModal({ onSelectIndex, theme, onClose, - onRunAgain + onRunAgain, + isExecuting = false }: ExecutionResultModalProps) { const [activeTab, setActiveTab] = useState<'response' | 'prompd' | 'metadata'>('response') + const [responseViewMode, setResponseViewMode] = useState<'preview' | 'source' | 'json'>('preview') + const [prompdViewMode, setPrompdViewMode] = useState<'preview' | 'source' | 'json'>('preview') const [copyFeedback, setCopyFeedback] = useState(null) + // Track whether we initiated a rerun so we can auto-navigate to the new result + const [waitingForResult, setWaitingForResult] = useState(false) + const historyLengthRef = useRef(executionHistory.length) + + // When a new result arrives after a rerun, auto-navigate to it (index 0) + useEffect(() => { + if (waitingForResult && executionHistory.length > historyLengthRef.current) { + setWaitingForResult(false) + historyLengthRef.current = executionHistory.length + onSelectIndex(0) + } + }, [executionHistory.length, waitingForResult, onSelectIndex]) + + // Keep ref in sync when history changes without rerun + useEffect(() => { + if (!waitingForResult) { + historyLengthRef.current = executionHistory.length + } + }, [executionHistory.length, waitingForResult]) + + // Try to parse response content as JSON for the JSON tree view + const responseJson = useMemo(() => { + if (!result.content) return null + return extractJson(result.content) + }, [result.content]) + + // Compiled prompt content and JSON representation + const compiledText = useMemo(() => { + if (!result.compiledPrompt) return '' + return typeof result.compiledPrompt === 'string' + ? result.compiledPrompt + : result.compiledPrompt.finalPrompt || '' + }, [result.compiledPrompt]) + + const compiledJson = useMemo(() => { + if (!result.compiledPrompt) return null + // If it's the structured object form, use it directly + if (typeof result.compiledPrompt === 'object') return result.compiledPrompt + // Otherwise try to parse the string as JSON + return extractJson(result.compiledPrompt)?.parsed ?? null + }, [result.compiledPrompt]) const handleCopy = () => { - const textToCopy = activeTab === 'prompd' - ? (typeof result.compiledPrompt === 'string' - ? result.compiledPrompt - : result.compiledPrompt?.finalPrompt || '') - : result.content + let textToCopy: string + if (activeTab === 'prompd') { + if (prompdViewMode === 'json' && compiledJson) { + textToCopy = JSON.stringify(compiledJson, null, 2) + } else { + textToCopy = compiledText + } + } else if (activeTab === 'response' && responseViewMode === 'json' && responseJson) { + textToCopy = JSON.stringify(responseJson.parsed, null, 2) + } else { + textToCopy = result.content + } navigator.clipboard.writeText(textToCopy) setCopyFeedback('Copied!') setTimeout(() => setCopyFeedback(null), 2000) } const isDark = theme === 'vs-dark' + const showLoading = waitingForResult && isExecuting return (
Execution Result - {result.status === 'success' ? ( + {showLoading ? ( + + + Running + + ) : result.status === 'success' ? ( - {new Date(result.timestamp).toLocaleString()} + {showLoading ? 'Executing...' : new Date(result.timestamp).toLocaleString()}
+ ) + })} +
+ ) +} + // Stat Card Component interface StatCardProps { icon: React.ReactNode @@ -923,3 +1188,74 @@ function MetadataList({ items, isDark }: MetadataListProps) {
) } + +// Skeleton loading bar with pulse animation +function SkeletonBar({ width = '60%', height = '14px', isDark }: { width?: string; height?: string; isDark: boolean }) { + return ( +
+ ) +} + +// Full loading placeholder for the content area +function LoadingContentPlaceholder({ isDark }: { isDark: boolean }) { + return ( +
+ +
+
+ Executing prompt... +
+
+ Waiting for response from the model +
+
+ {/* Skeleton lines to suggest where content will appear */} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/modules/editor/FileExplorer.tsx b/frontend/src/modules/editor/FileExplorer.tsx index f016a05..cef4b48 100644 --- a/frontend/src/modules/editor/FileExplorer.tsx +++ b/frontend/src/modules/editor/FileExplorer.tsx @@ -22,23 +22,12 @@ import { Link, Download, RefreshCw, - Sparkles + Sparkles, + Lightbulb } from 'lucide-react' import { SidebarPanelHeader } from '../components/SidebarPanelHeader' import { useConfirmDialog } from '../components/ConfirmDialog' - -type FileTypeKey = 'prmd' | 'pdflow' | 'prompdjson' | 'custom' - -const FILE_TYPES: Array<{ key: FileTypeKey; label: string; ext: string; description: string; icon: React.ReactNode }> = [ - { key: 'prmd', label: 'Prompt', ext: '.prmd', description: 'Prompd prompt file', - icon: prmd }, - { key: 'pdflow', label: 'Workflow', ext: '.pdflow', description: 'Visual workflow', - icon: pdflow }, - { key: 'prompdjson', label: 'Project Config', ext: 'prompd.json', description: 'Project configuration', - icon: }, - { key: 'custom', label: 'Other', ext: '', description: 'Any file type', - icon: }, -] +import { NewFileDialog, getDefaultContent } from '../components/NewFileDialog' type FileEntry = { name: string; handle?: any; kind: 'file' | 'folder'; path: string; fileObj?: File } @@ -60,7 +49,7 @@ type Props = { setEntriesExternal?: (list: FileEntry[]) => void onOpenPublish?: () => void // TabManager integration callbacks - onFileRenamed?: (oldPath: string, newPath: string, newHandle?: FileSystemFileHandle) => void + onFileRenamed?: (oldPath: string, newPath: string, newFilePath?: string, newHandle?: FileSystemFileHandle) => void onFileDeleted?: (filePath: string) => void onFilesRefreshed?: (entries: { name: string; path: string; handle?: FileSystemFileHandle }[]) => void // Local package opening callback @@ -79,9 +68,11 @@ type Props = { onOpenPrompdJson?: () => void // Install all dependencies from prompd.json onInstallDependencies?: () => void + // Open a brainstorm tab for collaborative editing + onBrainstorm?: (filePath: string, content: string, sourceTabId?: string) => void } -export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewPrompd, onAddToContentField, dirHandleExternal, setDirHandleExternal, entriesExternal, setEntriesExternal, onOpenPublish, onFileRenamed, onFileDeleted, onFilesRefreshed, onOpenLocalPackage, onWorkspacePathChanged, onAddIgnorePattern, ignorePatterns: externalIgnorePatterns = [], onCollapse, onOpenProjects, onOpenPrompdJson, onInstallDependencies }: Props) { +export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewPrompd, onAddToContentField, dirHandleExternal, setDirHandleExternal, entriesExternal, setEntriesExternal, onOpenPublish, onFileRenamed, onFileDeleted, onFilesRefreshed, onOpenLocalPackage, onWorkspacePathChanged, onAddIgnorePattern, ignorePatterns: externalIgnorePatterns = [], onCollapse, onOpenProjects, onOpenPrompdJson, onInstallDependencies, onBrainstorm }: Props) { const [dirHandleState, setDirHandleState] = useState(null) const [entriesState, setEntriesState] = useState([]) const dirHandle = dirHandleExternal !== undefined ? dirHandleExternal : dirHandleState @@ -92,6 +83,24 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP const [error, setError] = useState(null) const [expanded, setExpanded] = useState>(() => new Set([''])) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entry: FileEntry | TreeNode; type: 'file' | 'folder' } | null>(null) + const contextMenuRef = useRef(null) + // Clamp context menu to viewport after it renders + useEffect(() => { + const el = contextMenuRef.current + if (!el || !contextMenu) return + const rect = el.getBoundingClientRect() + const pad = 8 + let x = contextMenu.x + let y = contextMenu.y + if (rect.right > window.innerWidth - pad) x = window.innerWidth - rect.width - pad + if (rect.bottom > window.innerHeight - pad) y = window.innerHeight - rect.height - pad + if (x < pad) x = pad + if (y < pad) y = pad + if (x !== contextMenu.x || y !== contextMenu.y) { + el.style.left = `${x}px` + el.style.top = `${y}px` + } + }, [contextMenu]) const [showAddToSubmenu, setShowAddToSubmenu] = useState(false) const [showIgnoreSubmenu, setShowIgnoreSubmenu] = useState(false) const [clipboardData, setClipboardData] = useState<{ path: string; content?: string; type: 'file' | 'folder' } | null>(null) @@ -106,12 +115,13 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP const [inputDialog, setInputDialog] = useState<{ title: string defaultValue: string - showFileTypeSelector?: boolean onSubmit: (value: string) => void } | null>(null) const [inputDialogValue, setInputDialogValue] = useState('') - const [fileType, setFileType] = useState('prmd') - const [showAdvancedNewFile, setShowAdvancedNewFile] = useState(false) + + // New file dialog state (extracted component with file type selector) + const [showNewFileDialog, setShowNewFileDialog] = useState(false) + const [newFileTargetFolder, setNewFileTargetFolder] = useState('') // Confirmation dialog hook (replacement for confirm() which doesn't work in Electron) const { showConfirm, ConfirmDialogComponent } = useConfirmDialog() @@ -833,42 +843,27 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP } }, [clipboardData, dirHandle, entries, refresh]) - const createFileInFolder = useCallback(async (folderPath: string) => { + const createFileInFolder = useCallback((folderPath: string) => { if (!dirHandle) return - try { - setFileType('prmd') - setShowAdvancedNewFile(false) - - const fileName = await new Promise((resolve) => { - setInputDialogValue('untitled.prmd') - setInputDialog({ - title: 'New file:', - defaultValue: 'untitled.prmd', - showFileTypeSelector: true, - onSubmit: (value: string) => { - setInputDialog(null) - resolve(value || null) - } - }) - }) - if (!fileName) return - - const content = getDefaultContent(fileName) + setNewFileTargetFolder(folderPath) + setShowNewFileDialog(true) + }, [dirHandle]) - // Check if this is an Electron pseudo-handle + const handleNewFileSubmit = useCallback(async (fileName: string, content: string) => { + if (!dirHandle) return + setShowNewFileDialog(false) + try { const electronPath = (dirHandle as any)?._electronPath if (electronPath && (window as any).electronAPI?.writeFile) { - // Electron mode - use IPC to write the file - const fullPath = folderPath - ? `${electronPath}/${folderPath}/${fileName}`.replace(/\\/g, '/') + const fullPath = newFileTargetFolder + ? `${electronPath}/${newFileTargetFolder}/${fileName}`.replace(/\\/g, '/') : `${electronPath}/${fileName}`.replace(/\\/g, '/') const result = await (window as any).electronAPI.writeFile(fullPath, content) if (!result.success) { throw new Error(result.error || 'Failed to create file') } } else { - // File System Access API mode - const targetDir = await getDirectoryHandleForPath(dirHandle, folderPath) + const targetDir = await getDirectoryHandleForPath(dirHandle, newFileTargetFolder) const newFileHandle = await targetDir.getFileHandle(fileName, { create: true }) const writable = await newFileHandle.createWritable() await writable.write(content) @@ -879,7 +874,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP console.error('Create file failed:', err) setError('Failed to create file') } - }, [dirHandle, refresh]) + }, [dirHandle, newFileTargetFolder, refresh]) const createFolderInFolder = useCallback(async (folderPath: string) => { console.log('[FileExplorer] createFolderInFolder called with path:', folderPath) @@ -1069,7 +1064,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP close: async () => {} }) } - onFileRenamed(sourceFilePath, newPath, pseudoHandle as any) + onFileRenamed(sourceFilePath, newPath, targetFullPath, pseudoHandle as any) } } else { // Browser File System Access API mode @@ -1098,7 +1093,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP // Notify TabManager of file move const newPath = targetFolderPath ? `${targetFolderPath}/${fileName}` : fileName if (onFileRenamed) { - onFileRenamed(sourceFilePath, newPath, newFileHandle) + onFileRenamed(sourceFilePath, newPath, undefined, newFileHandle) } } @@ -1244,7 +1239,10 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP {entries.length === 0 ? (
{dirHandle ? ( - No files +
+ This folder is empty + Right-click to create a new file +
) : (
No workspace open @@ -1322,6 +1320,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP ) : null} {contextMenu ? (
Copy Contents
+ {/* Brainstorm - collaborative editing with AI */} + {onBrainstorm && ( + <> +
+
{ + try { + const entry = entries.find(e => e.path === contextMenu.entry.path) + if (entry?.handle?._electronPath && window.electronAPI?.isElectron) { + const fullPath = entry.handle._electronPath + const result = await window.electronAPI.readFile(fullPath) + if (result?.success && result.content) { + onBrainstorm(fullPath, result.content) + } + } else if (entry?.handle && typeof (entry.handle as FileSystemFileHandle).getFile === 'function') { + const file = await (entry.handle as FileSystemFileHandle).getFile() + const text = await file.text() + onBrainstorm(entry.path, text) + } + } catch (err) { + console.error('Failed to open brainstorm:', err) + } + setContextMenu(null) + }}> + + Brainstorm +
+ + )} + {/* Install Dependencies - only for prompd.json files */} {onInstallDependencies && contextMenu.entry.name === 'prompd.json' && ( <> @@ -1537,7 +1565,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP close: async () => {} }) } - onFileRenamed(oldPath, newPath, pseudoHandle as any) + onFileRenamed(oldPath, newPath, newFullPath, pseudoHandle as any) } } else { // Browser File System Access API mode @@ -1553,7 +1581,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP // Notify TabManager of file rename const newPath = parentPath ? `${parentPath}/${newName}` : newName if (onFileRenamed) { - onFileRenamed(oldPath, newPath, newFileHandle) + onFileRenamed(oldPath, newPath, undefined, newFileHandle) } } @@ -1673,7 +1701,7 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP
) : null} - {/* Input Dialog (replacement for prompt()) */} + {/* Input Dialog for rename/new folder (simple text input) */} {inputDialog && (
e.stopPropagation()} > -
-

- {inputDialog.title} -

- {inputDialog.showFileTypeSelector && ( - - )} -
- - {/* File type selector cards */} - {inputDialog.showFileTypeSelector && !showAdvancedNewFile && ( -
- {FILE_TYPES.map(ft => ( - - ))} -
- )} - +

+ {inputDialog.title} +

{ - setInputDialogValue(e.target.value) - // Sync file type from typed extension - if (inputDialog.showFileTypeSelector) { - const val = e.target.value - if (val.endsWith('.prmd')) setFileType('prmd') - else if (val.endsWith('.pdflow')) setFileType('pdflow') - else if (val === 'prompd.json') setFileType('prompdjson') - else setFileType('custom') - } - }} + onChange={(e) => setInputDialogValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { inputDialog.onSubmit(inputDialogValue) @@ -1832,6 +1787,13 @@ export default function FileExplorer({ currentFileName, onOpenFile, onCreateNewP
)} + {/* New File Dialog (with file type selector) */} + setShowNewFileDialog(false)} + onSubmit={handleNewFileSubmit} + /> + {/* Confirmation Dialog (using reusable component) */}
@@ -1873,7 +1835,18 @@ function iconFor(name: string, isIgnored = false) { ) } if (lower.endsWith('.pdpkg')) { - return + return ( + pdpkg + ) } // Common file types with VS Code colors @@ -1912,63 +1885,6 @@ function iconFor(name: string, isIgnored = false) { return } -function formatFileName(fileName: string): { id: string; name: string } { - // Strip extension - const dotIdx = fileName.lastIndexOf('.') - const baseName = dotIdx > 0 ? fileName.substring(0, dotIdx) : fileName - // id: kebab-case, lowercase, replace underscores/spaces with hyphens - const id = baseName.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '') - // name: Title case, split on hyphens/underscores/spaces - const name = baseName - .replace(/[-_]+/g, ' ') - .replace(/\b\w/g, c => c.toUpperCase()) - .trim() - return { id: id || 'untitled', name: name || 'Untitled' } -} - -function defaultPrompd(fileName?: string) { - const { id, name } = formatFileName(fileName || 'untitled.prmd') - return `--- -id: ${id} -name: ${name} -description: "" -version: 1.0.0 ---- - -# User - -` -} - -function defaultWorkflow(fileName?: string) { - const { id, name } = formatFileName(fileName || 'new-workflow.pdflow') - return JSON.stringify({ - version: '1.0.0', - metadata: { - id, - name, - description: '' - }, - parameters: [], - nodes: [], - edges: [] - }, null, 2) + '\n' -} - -function defaultPrompdJson() { - return JSON.stringify({ - name: 'untitled', - version: '1.0.0', - description: '' - }, null, 2) + '\n' -} - -function getDefaultContent(fileName: string): string { - if (fileName.endsWith('.prmd')) return defaultPrompd(fileName) - if (fileName.endsWith('.pdflow')) return defaultWorkflow(fileName) - if (fileName === 'prompd.json') return defaultPrompdJson() - return '' -} type ProjectRec = { mode: 'fs' | 'upload'; name: string; top: string; snapshot?: FileEntry[]; ts?: number } function getProjects(): ProjectRec[] { try { return JSON.parse(localStorage.getItem('prompd.projects') || '[]') } catch { return [] } } diff --git a/frontend/src/modules/editor/InstalledResourcesPanel.tsx b/frontend/src/modules/editor/InstalledResourcesPanel.tsx new file mode 100644 index 0000000..281d95e --- /dev/null +++ b/frontend/src/modules/editor/InstalledResourcesPanel.tsx @@ -0,0 +1,538 @@ +import { useState, useEffect, useCallback } from 'react' +import { Package, RefreshCw, Trash2, Upload, ChevronRight, Globe, HardDrive, Loader2, AlertCircle, Play } from 'lucide-react' +import { SidebarPanelHeader } from '../components/SidebarPanelHeader' +import { useConfirmDialog } from '../components/ConfirmDialog' +import { RESOURCE_TYPE_LABELS, RESOURCE_TYPE_ICONS, RESOURCE_TYPE_COLORS, type ResourceType } from '../services/resourceTypes' +import type { PublishResourceInfo } from '../components/PublishResourceModal' + +type FilterTab = 'all' | ResourceType + +interface InstalledResource { + name: string + version: string + type: string + scope: 'workspace' | 'user' + path: string + description?: string + tools?: string[] + mcps?: string[] + main?: string + isArchive?: boolean + origin?: 'local' | 'registry' +} + +interface Props { + theme?: 'light' | 'dark' + workspacePath?: string | null + onCollapse?: () => void + onPublish?: (resource: PublishResourceInfo, manifest: Record) => void + onShowNotification?: (message: string, type: 'info' | 'success' | 'warning' | 'error') => void +} + +export default function InstalledResourcesPanel({ theme = 'dark', workspacePath, onCollapse, onPublish, onShowNotification }: Props) { + const [activeFilter, setActiveFilter] = useState('all') + const [resources, setResources] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedResource, setExpandedResource] = useState(null) + const [deletingPath, setDeletingPath] = useState(null) + const [deployingPath, setDeployingPath] = useState(null) + const { showConfirm, ConfirmDialogComponent } = useConfirmDialog(theme) + + const electronAPI = (window as unknown as Record).electronAPI as { + isElectron?: boolean + resource?: { + listInstalled: (workspacePath: string) => Promise<{ + success: boolean + resources: InstalledResource[] + error?: string + }> + delete: (resourcePath: string) => Promise<{ success: boolean; error?: string }> + getManifest: (resourcePath: string) => Promise<{ + success: boolean + manifest?: Record + error?: string + }> + } + package?: { + uninstall: (packageName: string, workspacePath: string, options?: { + global?: boolean + }) => Promise<{ success: boolean; name?: string; error?: string }> + } + } | undefined + + const loadResources = useCallback(async () => { + if (!electronAPI?.isElectron || !electronAPI.resource) return + + setLoading(true) + setError(null) + try { + const result = await electronAPI.resource.listInstalled(workspacePath || '') + if (result.success) { + setResources(result.resources || []) + } else { + setError(result.error || 'Failed to load resources') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load resources') + } finally { + setLoading(false) + } + }, [workspacePath, electronAPI?.isElectron]) + + useEffect(() => { + loadResources() + }, [loadResources]) + + // Auto-refresh when resources change (e.g. after package install) + useEffect(() => { + const handler = () => loadResources() + window.addEventListener('prompd:resources-changed', handler) + return () => window.removeEventListener('prompd:resources-changed', handler) + }, [loadResources]) + + const handleDelete = useCallback(async (resource: InstalledResource) => { + if (!electronAPI?.resource) return + const confirmed = await showConfirm({ + title: 'Delete Resource', + message: 'Delete this installed resource? This cannot be undone.', + confirmLabel: 'Delete', + confirmVariant: 'danger' + }) + if (!confirmed) return + + setDeletingPath(resource.path) + try { + // Use CLI uninstall for registry packages (handles prompd.json cleanup) + if (resource.origin !== 'local' && electronAPI.package?.uninstall && workspacePath) { + const nameWithVersion = `${resource.name}@${resource.version}` + const result = await electronAPI.package.uninstall(nameWithVersion, workspacePath, { + global: resource.scope === 'user' + }) + if (result.success) { + setResources(prev => prev.filter(r => r.path !== resource.path)) + window.dispatchEvent(new Event('prompd:resources-changed')) + } else { + setError(result.error || 'Failed to uninstall resource') + } + } else { + // Fallback: direct file deletion for local exports or when CLI unavailable + const result = await electronAPI.resource.delete(resource.path) + if (result.success) { + setResources(prev => prev.filter(r => r.path !== resource.path)) + window.dispatchEvent(new Event('prompd:resources-changed')) + } else { + setError(result.error || 'Failed to delete resource') + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete') + } finally { + setDeletingPath(null) + } + }, [electronAPI?.resource, electronAPI?.package, workspacePath, showConfirm]) + + const handlePublish = useCallback(async (resource: InstalledResource) => { + if (!electronAPI?.resource || !onPublish) return + + try { + const result = await electronAPI.resource.getManifest(resource.path) + if (result.success && result.manifest) { + onPublish(resource as PublishResourceInfo, result.manifest as Record) + } else { + setError(result.error || 'Failed to read manifest') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to read manifest') + } + }, [electronAPI?.resource, onPublish]) + + const handleDeploy = useCallback(async (resource: InstalledResource) => { + if (!resource.path) return + + setDeployingPath(resource.path) + setError(null) + try { + const deployAPI = (window as unknown as Record).electronAPI as { + deployment?: { + deploy: (path: string, options: Record) => Promise<{ success: boolean; deploymentId?: string; error?: string }> + } + } | undefined + + if (!deployAPI?.deployment?.deploy) { + setError('Deployment requires Electron environment') + return + } + + const result = await deployAPI.deployment.deploy(resource.path, { + name: resource.name, + version: resource.version, + }) + + if (result.success) { + onShowNotification?.(`Deployed ${resource.name} v${resource.version}`, 'success') + window.dispatchEvent(new CustomEvent('deployment-updated')) + } else { + setError(result.error || 'Deployment failed') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Deployment failed') + } finally { + setDeployingPath(null) + } + }, [onShowNotification]) + + const filteredResources = activeFilter === 'all' + ? resources + : resources.filter(r => r.type === activeFilter) + + const filterTabs: { key: FilterTab; label: string }[] = [ + { key: 'all', label: `All (${resources.length})` }, + { key: 'package', label: `Packages` }, + { key: 'workflow', label: `Workflows` }, + { key: 'node-template', label: `Templates` }, + { key: 'skill', label: `Skills` }, + ] + + return ( +
+ + + + + {/* Filter tabs */} +
+ {filterTabs.map(tab => ( + + ))} +
+ + {/* Content */} +
+ {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Loading */} + {loading && resources.length === 0 && ( +
+ + Scanning resources... +
+ )} + + {/* Empty state */} + {!loading && filteredResources.length === 0 && ( +
+ +

+ {activeFilter === 'all' ? 'No installed resources' : `No installed ${RESOURCE_TYPE_LABELS[activeFilter as ResourceType] || activeFilter}s`} +

+

+ Install resources from the registry or deploy locally +

+
+ )} + + {/* Resource list */} + {filteredResources.map(resource => { + const TypeIcon = RESOURCE_TYPE_ICONS[resource.type as ResourceType] || Package + const typeColor = RESOURCE_TYPE_COLORS[resource.type as ResourceType] || '#3b82f6' + const isExpanded = expandedResource === resource.path + const isDeleting = deletingPath === resource.path + + return ( +
+ {/* Resource header */} + + + {/* Expanded details */} + {isExpanded && ( +
+ {resource.description && ( +

+ {resource.description} +

+ )} + + {/* Tools (skill) */} + {resource.tools && resource.tools.length > 0 && ( +
+ Tools: + {resource.tools.join(', ')} +
+ )} + + {/* MCPs */} + {resource.mcps && resource.mcps.length > 0 && ( +
+ MCPs: + {resource.mcps.join(', ')} +
+ )} + + {/* Path */} +
+ {resource.path} +
+ + {/* Actions */} +
+ {resource.type === 'workflow' && ( + + )} + {onPublish && ( + + )} + +
+
+ )} +
+ ) + })} +
+ +
+ ) +} diff --git a/frontend/src/modules/editor/LocalPackageModal.tsx b/frontend/src/modules/editor/LocalPackageModal.tsx index fdfa71a..56d1947 100644 --- a/frontend/src/modules/editor/LocalPackageModal.tsx +++ b/frontend/src/modules/editor/LocalPackageModal.tsx @@ -1,16 +1,18 @@ import { useState, useEffect } from 'react' -import { X, Package, FileText, FolderOpen, Folder, ChevronRight, ChevronDown, HardDrive, BookOpen } from 'lucide-react' +import { X, Package, FileText, FolderOpen, Folder, ChevronRight, ChevronDown, BookOpen } from 'lucide-react' import { packageCache, type FileNode } from '../services/packageCache' import Editor from '@monaco-editor/react' import { useUIStore, selectTheme } from '../../stores/uiStore' import { getMonacoTheme } from '../lib/monacoConfig' import MarkdownPreview from '../components/MarkdownPreview' import { stripContentFrontmatter } from '../lib/prompdParser' +import { RESOURCE_TYPE_ICONS, RESOURCE_TYPE_COLORS, RESOURCE_TYPE_LABELS, type ResourceType } from '../services/resourceTypes' interface LocalPackageInfo { manifest: { name: string version: string + type?: string description?: string author?: string main?: string @@ -184,26 +186,45 @@ export default function LocalPackageModal({ packageInfo, onClose, onOpenInEditor alignItems: 'center', justifyContent: 'space-between' }}> + {(() => { + const localType = (manifest?.type || 'package') as ResourceType + const LocalTypeIcon = RESOURCE_TYPE_ICONS[localType] || Package + const localTypeColor = RESOURCE_TYPE_COLORS[localType] || '#3b82f6' + return (
- +
-
- {displayName} +
+
+ {displayName} +
+
+ {RESOURCE_TYPE_LABELS[localType] || localType} +
+ ) + })()}
)} - {pkg.type && ( -
- - Type: - {pkg.type} -
- )} + {(() => { + const detailType = (pkg.type || 'package') as ResourceType + const DetailTypeIcon = RESOURCE_TYPE_ICONS[detailType] || Package + const detailTypeColor = RESOURCE_TYPE_COLORS[detailType] || '#3b82f6' + return ( +
+ + Type: + + {RESOURCE_TYPE_LABELS[detailType] || pkg.type} + +
+ ) + })()} {formatDate(pkg.publishedAt) && (
diff --git a/frontend/src/modules/editor/PackagePanel.tsx b/frontend/src/modules/editor/PackagePanel.tsx index 4f6f5e2..5f57c76 100644 --- a/frontend/src/modules/editor/PackagePanel.tsx +++ b/frontend/src/modules/editor/PackagePanel.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback } from 'react' -import { Search, Package, Download, ExternalLink, RefreshCw, Calendar, User, Tag, Star, Loader2 } from 'lucide-react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { Search, Package, Download, ExternalLink, RefreshCw, Calendar, User, Tag, Star, Loader2, Globe, HardDrive, ChevronDown } from 'lucide-react' import { registryApi, type RegistryPackage } from '../services/registryApi' import PackageDetailsModal from './PackageDetailsModal' import { useAuthenticatedUser } from '../auth/ClerkWrapper' import { SidebarPanelHeader } from '../components/SidebarPanelHeader' +import { RESOURCE_TYPE_ICONS, RESOURCE_TYPE_COLORS, RESOURCE_TYPE_LABELS, RESOURCE_TYPES, type ResourceType } from '../services/resourceTypes' type TabKey = 'search' | 'my-packages' @@ -25,6 +26,7 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe // Search tab state const [searchQuery, setSearchQuery] = useState(initialSearchQuery || '') const [hasSearched, setHasSearched] = useState(false) // Track if user has initiated a search + const [typeFilter, setTypeFilter] = useState(null) // When initialSearchQuery changes, update the search and switch to search tab useEffect(() => { @@ -49,6 +51,7 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe const [myPackagesError, setMyPackagesError] = useState(null) const [myPackagesLoaded, setMyPackagesLoaded] = useState(false) const [myPackagesFilter, setMyPackagesFilter] = useState('') + const [myTypeFilter, setMyTypeFilter] = useState(null) // Modal state const [selectedPackage, setSelectedPackage] = useState(null) @@ -58,6 +61,12 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe { key: 'my-packages', label: 'My Packages', icon: } ] + // Filter packages by resource type + const filterByType = (pkgs: RegistryPackage[], filter: ResourceType | null) => { + if (!filter) return pkgs + return pkgs.filter(p => (p.type || 'package') === filter) + } + // Debounced package search - only when user has initiated useEffect(() => { if (activeTab !== 'search' || !hasSearched) return @@ -132,7 +141,7 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe setSelectedPackage(pkg) } - const handleInstallPackage = useCallback(async (pkg: RegistryPackage) => { + const handleInstallPackage = useCallback(async (pkg: RegistryPackage, global?: boolean) => { if (!workspacePath) { onShowNotification?.('No workspace open. Open a folder first.', 'warning') return @@ -142,10 +151,15 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe return } const ref = `${pkg.name}@${pkg.version}` + const scope = global ? 'global' : 'project' try { - const result = await window.electronAPI.package.install(ref, workspacePath) + const result = await window.electronAPI.package.install(ref, workspacePath, { + type: pkg.type as ResourceType, + global: global || false, + }) if (result.success) { - onShowNotification?.(`Installed ${ref}`, 'info') + onShowNotification?.(`Installed ${ref} (${scope})`, 'info') + window.dispatchEvent(new Event('prompd:resources-changed')) } else { onShowNotification?.(result.error || 'Install failed', 'error') } @@ -214,15 +228,15 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe
{activeTab === 'search' && ( -
+
{/* Search input */}
- { if (!highlightSearch) e.currentTarget.style.borderColor = 'var(--accent)' @@ -255,8 +270,8 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe /> {searchQuery.length > 0 && searchQuery.length < 2 && (
@@ -265,17 +280,20 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe )}
+ {/* Type filter chips */} + + {/* Loading state */} {loading && (
-
Searching registry...
@@ -284,48 +302,63 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe {/* Error state */} {error && !loading && (
Error: {error}
)} - {/* Package grid */} - {!loading && !error && packages.length > 0 && ( -
- {packages.map((pkg) => ( - handlePackageClick(pkg)} - onInstall={handleInstallPackage} - /> - ))} -
- )} + {/* Package list */} + {!loading && !error && packages.length > 0 && (() => { + const filtered = filterByType(packages, typeFilter) + return filtered.length > 0 ? ( +
+ {filtered.map((pkg) => ( + handlePackageClick(pkg)} + onInstall={handleInstallPackage} + /> + ))} +
+ ) : ( +
+ +
No {RESOURCE_TYPE_LABELS[typeFilter!].toLowerCase()}s found
+
+ Try removing the type filter +
+
+ ) + })()} {/* Empty state */} {!loading && !error && packages.length === 0 && searchQuery.length >= 2 && (
- +
No packages found for "{searchQuery}"
-
+
Try a different search term
@@ -334,14 +367,14 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe {/* Initial empty state */} {!loading && !error && packages.length === 0 && searchQuery.length === 0 && (
- +
Search for packages from the registry
-
+
Start typing to discover packages
@@ -350,10 +383,10 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe )} {activeTab === 'my-packages' && ( -
+
{/* Header with refresh button */}
-

+

My Published Packages

{isSignedIn && ( @@ -364,7 +397,7 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe }} disabled={myPackagesLoading} style={{ - padding: '6px 10px', + padding: '4px 8px', fontSize: '11px', background: 'var(--panel-2)', border: '1px solid var(--border)', @@ -377,7 +410,7 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe opacity: myPackagesLoading ? 0.6 : 1 }} > - + Refresh )} @@ -386,9 +419,9 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe {/* Filter input */} {isSignedIn && myPackages.length > 0 && (
- e.currentTarget.style.borderColor = 'var(--accent)'} @@ -416,17 +450,22 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe
)} + {/* Type filter chips */} + {isSignedIn && myPackages.length > 0 && ( + + )} + {/* Not signed in state */} {!isSignedIn && (
- +
Sign in to view your packages
-
+
Your published packages will appear here
@@ -435,14 +474,14 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe {/* Loading state */} {isSignedIn && myPackagesLoading && (
-
Loading your packages...
@@ -451,34 +490,34 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe {/* Error state */} {isSignedIn && myPackagesError && !myPackagesLoading && (
Error: {myPackagesError}
)} - {/* Package grid */} + {/* Package list */} {isSignedIn && !myPackagesLoading && !myPackagesError && myPackages.length > 0 && (() => { - const filteredPackages = myPackagesFilter + let filteredPackages = myPackagesFilter ? myPackages.filter(pkg => pkg.name.toLowerCase().includes(myPackagesFilter.toLowerCase()) || pkg.description?.toLowerCase().includes(myPackagesFilter.toLowerCase()) || pkg.keywords?.some(k => k.toLowerCase().includes(myPackagesFilter.toLowerCase())) ) : myPackages + filteredPackages = filterByType(filteredPackages, myTypeFilter) return filteredPackages.length > 0 ? (
{filteredPackages.map((pkg) => ( ) : (
- -
No packages match "{myPackagesFilter}"
-
- Try a different search term + +
No packages match your filters
+
+ Try a different search term or type filter
) @@ -507,14 +546,14 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe {/* Empty state */} {isSignedIn && !myPackagesLoading && !myPackagesError && myPackages.length === 0 && myPackagesLoaded && (
- +
No packages published yet
-
+
Publish a package to see it here
@@ -536,30 +575,91 @@ export default function PackagePanel({ theme = 'dark', onOpenInEditor, onUseAsTe ) } -// PackageCard component +// Type filter chips component +interface TypeFilterChipsProps { + activeFilter: ResourceType | null + onFilterChange: (filter: ResourceType | null) => void +} + +function TypeFilterChips({ activeFilter, onFilterChange }: TypeFilterChipsProps) { + return ( +
+ + {RESOURCE_TYPES.map(rt => { + const Icon = RESOURCE_TYPE_ICONS[rt] + const color = RESOURCE_TYPE_COLORS[rt] + const isActive = activeFilter === rt + return ( + + ) + })} +
+ ) +} + +// PackageCard component — compact sidebar-friendly layout interface PackageCardProps { package: RegistryPackage onClick: () => void - onInstall?: (pkg: RegistryPackage) => Promise + onInstall?: (pkg: RegistryPackage, global?: boolean) => Promise } function PackageCard({ package: pkg, onClick, onInstall }: PackageCardProps) { const [isHovered, setIsHovered] = useState(false) const [installing, setInstalling] = useState(false) + const [showScopeMenu, setShowScopeMenu] = useState(false) + const scopeMenuRef = useRef(null) - // Format publish date - const publishedDate = pkg.publishedAt ? new Date(pkg.publishedAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }) : null + // Close scope menu on click outside + useEffect(() => { + if (!showScopeMenu) return + const handleClickOutside = (e: MouseEvent) => { + if (scopeMenuRef.current && !scopeMenuRef.current.contains(e.target as Node)) { + setShowScopeMenu(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showScopeMenu]) - // Format updated date - const updatedDate = pkg.updatedAt ? new Date(pkg.updatedAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }) : null + const pkgType = (pkg.type || 'package') as ResourceType + const TypeIcon = RESOURCE_TYPE_ICONS[pkgType] || Package + const typeColor = RESOURCE_TYPE_COLORS[pkgType] || '#3b82f6' return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} style={{ - position: 'relative', - padding: '20px', - background: 'var(--panel)', - borderWidth: '2px', - borderStyle: 'solid', - borderColor: isHovered ? 'var(--accent)' : 'var(--border)', - borderRadius: '12px', + padding: '10px 12px', + background: isHovered ? 'var(--panel-2)' : 'var(--panel)', + border: `1px solid ${isHovered ? typeColor + '60' : 'var(--border)'}`, + borderRadius: '8px', cursor: 'pointer', - transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', + transition: 'all 0.15s', display: 'flex', flexDirection: 'column', - gap: '16px', - minHeight: '240px', - boxShadow: isHovered - ? '0 8px 24px rgba(0, 0, 0, 0.12)' - : '0 2px 8px rgba(0, 0, 0, 0.04)', - transform: isHovered ? 'translateY(-2px)' : 'translateY(0)' + gap: '8px', }} > - {/* Package header with icon */} -
+ {/* Row 1: Icon + Name + Version + Install */} +
- +
{pkg.name}
-
- - v{pkg.version} -
-
- {/* Install button + hover indicator */} -
- {onInstall && ( - - )} - -
-
- - {/* Description */} -
- {pkg.description || 'No description available'} -
- - {/* Metadata sections */} -
- {/* Primary metadata - 2 column grid */} -
- {/* Author */} - {pkg.author && ( -
- - - {pkg.author} - -
- )} - - {/* Downloads */} - {pkg.downloads !== undefined && ( -
+ - - {formatDownloads(pkg.downloads)} downloads -
- )} - - {/* Published date */} - {publishedDate && ( -
- - Published {publishedDate} -
- )} - - {/* Updated date */} - {updatedDate && publishedDate !== updatedDate && ( -
+ - - Updated {updatedDate} -
- )} - - {/* License */} - {pkg.license && ( + {RESOURCE_TYPE_LABELS[pkgType]} + +
+
+ {/* Install button with scope dropdown */} + {onInstall && ( +
- - {pkg.license} -
- )} -
- - {/* Keywords/Tags */} - {pkg.keywords && pkg.keywords.length > 0 && ( -
- {pkg.keywords.slice(0, 5).map((keyword, idx) => ( - { + e.stopPropagation() + if (installing) return + setInstalling(true) + onInstall(pkg, false).finally(() => setInstalling(false)) + }} + title={`Install ${pkg.name}@${pkg.version} to project`} style={{ - padding: '4px 12px', - fontSize: '11px', - fontWeight: 500, - background: isHovered ? 'rgba(99, 102, 241, 0.1)' : 'var(--panel-2)', - borderWidth: '1px', - borderStyle: 'solid', - borderColor: isHovered ? 'rgba(99, 102, 241, 0.3)' : 'var(--border)', - borderRadius: '14px', - color: isHovered ? 'var(--accent)' : 'var(--text-secondary)', - whiteSpace: 'nowrap', - transition: 'all 0.2s' + padding: '3px 7px', + background: 'transparent', + border: 'none', + color: 'var(--accent)', + cursor: installing ? 'default' : 'pointer', + fontSize: '10px', + fontWeight: 600, + display: 'flex', + alignItems: 'center', + gap: '3px', }} > - {keyword} - - ))} - {pkg.keywords.length > 5 && ( - + : + } + {installing ? '...' : 'Install'} + + +
+ {/* Scope dropdown */} + {showScopeMenu && ( +
e.stopPropagation()} + style={{ + position: 'absolute', + right: 0, + top: '100%', + marginTop: 4, + background: 'var(--panel)', + border: '1px solid var(--border)', + borderRadius: 6, + boxShadow: '0 4px 12px rgba(0,0,0,0.2)', + zIndex: 100, + minWidth: 140, + overflow: 'hidden', }} > - +{pkg.keywords.length - 5} more - + + +
)}
)} + {!onInstall && ( + + )} +
+ + {/* Row 2: Description */} + {pkg.description && ( +
+ {pkg.description} +
+ )} + + {/* Row 3: Compact metadata */} +
+ {pkg.author && ( + + + {pkg.author} + + )} + {pkg.downloads !== undefined && pkg.downloads > 0 && ( + + + {formatDownloads(pkg.downloads)} + + )} + {pkg.keywords && pkg.keywords.length > 0 && ( + + + {pkg.keywords.slice(0, 2).join(', ')} + {pkg.keywords.length > 2 && ` +${pkg.keywords.length - 2}`} + + )}
) diff --git a/frontend/src/modules/editor/PrompdExecutionTab.tsx b/frontend/src/modules/editor/PrompdExecutionTab.tsx index ed73095..1537bd6 100644 --- a/frontend/src/modules/editor/PrompdExecutionTab.tsx +++ b/frontend/src/modules/editor/PrompdExecutionTab.tsx @@ -19,6 +19,7 @@ import { useAuthenticatedUser } from '../auth/ClerkWrapper' import { ExecutionResultModal, type ExecutionResult } from './ExecutionResultModal' import { GenerationControls, type GenerationMode } from '../components/GenerationControls' import { CompiledPreview } from './CompiledPreview' +import { modelSupportsImageGeneration } from '../services/executionService' // Re-export GenerationMode for external consumers export type { GenerationMode } @@ -50,6 +51,7 @@ export interface ExecutionConfig { maxTokens?: number // Max tokens to generate (default: 4096) temperature?: number // Temperature 0-2 (default: 0.7) mode?: GenerationMode // Generation mode (default: 'default') + imageGeneration?: boolean // Enable image generation (default: true when model supports it) // Workspace context for package resolution workspacePath?: string // Root workspace path } @@ -249,6 +251,9 @@ export function PrompdExecutionTab({ onModeChange={(mode) => onConfigChange({ mode })} theme={theme} provider={config.provider} + imageGeneration={config.imageGeneration ?? true} + onImageGenerationChange={(enabled) => onConfigChange({ imageGeneration: enabled })} + modelSupportsImageGeneration={modelSupportsImageGeneration(config.provider, config.model)} /> {language &&
{language.charAt(0).toUpperCase() + language.slice(1)}
}
)} - {t.dirty ? : null} - { e.stopPropagation(); onClose(t.id) }}>✕ + {t.dirty ? : null} +
))}
@@ -236,7 +236,7 @@ export default function TabsBar({ tabs, activeTabId, onActivate, onClose, onClos {contextMenu && (() => { // Find the tab to check its type const contextTab = tabs.find(t => t.id === contextMenu.tabId) - const isNonSaveableTab = contextTab?.type === 'chat' || contextTab?.type === 'execution' + const isNonSaveableTab = contextTab?.type === 'chat' || contextTab?.type === 'execution' || contextTab?.type === 'brainstorm' return (
state.updateNodeData) const showContextMenu = useWorkflowStore(state => state.showContextMenu) const hideContextMenu = useWorkflowStore(state => state.hideContextMenu) + const groupNodes = useWorkflowStore(state => state.groupNodes) // Editor store for getting current tab info const tabs = useEditorStore(state => state.tabs) @@ -931,6 +932,17 @@ function WorkflowCanvasInner({ content, activeTabId, onChange, readOnly = false, hideContextMenu() }, [contextMenu, hideContextMenu]) + const handleContextMenuGroupSelected = useCallback(() => { + const selectedIds = reactFlowInstance + .getNodes() + .filter(n => n.selected) + .map(n => n.id) + if (selectedIds.length >= 2) { + groupNodes(selectedIds) + } + hideContextMenu() + }, [reactFlowInstance, groupNodes, hideContextMenu]) + const handleSaveTemplate = useCallback(async (name: string, description: string, scope: TemplateScope) => { if (!saveTemplateNodeId) return if (!window.electronAPI?.templates) { @@ -1106,10 +1118,11 @@ function WorkflowCanvasInner({ content, activeTabId, onChange, readOnly = false, return } - // Validate template has node data (supports both nested and legacy flat format) + // Validate template has node data in the 'node-template' section const tpl = result.template as Record - const nodeInfo = (tpl.node || tpl) as Record - if (!nodeInfo.nodeType || !nodeInfo.nodeData) { + const ntSec = tpl['node-template'] as Record | undefined + const nodeInfo = ntSec?.node as Record | undefined + if (!nodeInfo?.nodeType || !nodeInfo?.nodeData) { useUIStore.getState().addToast('Invalid template format', 'error') return } @@ -1459,6 +1472,7 @@ function WorkflowCanvasInner({ content, activeTabId, onChange, readOnly = false, return { success: true, response: result.response || '', + thinking: result.thinking, } } catch (error) { return { @@ -1474,6 +1488,24 @@ function WorkflowCanvasInner({ content, activeTabId, onChange, readOnly = false, try { const result = await executor.execute() setExecutionResult(result) + + // Archive to execution history + { + const wfState = useWorkflowStore.getState() + const edState = useEditorStore.getState() + const wfName = (activeTabId ? edState.tabs.find(t => t.id === activeTabId)?.name : null) || 'Workflow' + wfState.addToExecutionHistory({ + id: `exec-${Date.now()}`, + workflowName: wfName.replace(/\.pdflow$/, ''), + status: result.success ? 'success' : 'error', + timestamp: Date.now(), + duration: result.metrics?.totalDuration || 0, + result: { ...result, trace: (result as unknown as { trace?: ExecutionTrace }).trace }, + checkpoints: [...wfState.checkpoints], + promptsSent: [...wfState.promptsSent], + }) + } + // Preserve nodeStates from last progress update so debug footers persist const currentState = useWorkflowStore.getState().executionState setExecutionState({ @@ -1702,6 +1734,30 @@ function WorkflowCanvasInner({ content, activeTabId, onChange, readOnly = false, selectionKeyCode="Shift" multiSelectionKeyCode={['Meta', 'Control']} > + {/* Empty canvas guidance for new users */} + {nodes.length === 0 && !readOnly && ( +
+
+ Start Building Your Workflow +
+
+ Right-click on the canvas to add nodes, or drag them from the Node Palette on the left. +
+
+ Every workflow begins with a Trigger node and ends with an Output node. +
+
+ )} + {/* Background grid */} {showGrid && ( n.selected).length} onAddNode={handleContextMenuAddNode} onInsertTemplate={handleContextMenuInsertTemplate} onHighlightPath={handleContextMenuHighlightPath} diff --git a/frontend/src/modules/hooks/index.ts b/frontend/src/modules/hooks/index.ts index 7ba323b..23510af 100644 --- a/frontend/src/modules/hooks/index.ts +++ b/frontend/src/modules/hooks/index.ts @@ -5,3 +5,5 @@ export { useTabManager } from './useTabManager' export type { TabManager, OpenFileOptions } from './useTabManager' export { useMcpTools } from './useMcpTools' +export { useInstalledSkills } from './useInstalledSkills' +export type { InstalledSkill } from './useInstalledSkills' diff --git a/frontend/src/modules/hooks/useAgentMode.ts b/frontend/src/modules/hooks/useAgentMode.ts index 7ef1da7..deb4371 100644 --- a/frontend/src/modules/hooks/useAgentMode.ts +++ b/frontend/src/modules/hooks/useAgentMode.ts @@ -63,7 +63,7 @@ export interface AgentState { /** Pending ask_user question waiting for user input */ pendingAskUser: { question: string - options?: Array<{ label: string; description?: string }> + options?: Array<{ label: string; description?: string } | string> resolve: (answer: string) => void } | null /** Pending plan approval waiting for user decision */ @@ -124,7 +124,7 @@ export interface AgentChatActions { createAgentLLMClient: ( baseClient: AgentCompatibleLLMClient, chatRef: React.RefObject, - contextMessages?: Array<{ role: 'system'; content: string }> + contextMessages?: Array<{ role: 'system'; content: string }> | (() => Array<{ role: 'system'; content: string }>) ) => AgentCompatibleLLMClient // Cancel pending ask_user cancelAskUser: () => void @@ -140,6 +140,18 @@ export interface AgentChatActions { stop: () => void } +// Simple string hash for duplicate-write detection (djb2) +function hashString(str: string): number { + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0 + } + return hash +} + +/** Max consecutive duplicate writes before the loop is stopped */ +const MAX_DUPLICATE_WRITES = 2 + // ============================================================================ // Hook // ============================================================================ @@ -197,9 +209,13 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh const chatModeOverrideRef = useRef(null) // Ref to track repeated tool failures on the same file (loop detection) const lastFailedToolRef = useRef<{ tool: string; path: string; count: number } | null>(null) + // Total iteration counter for current agent loop (enforces maxIterations) + const agentLoopIterationRef = useRef(0) + // Track last write_file content to detect duplicate-write loops + const lastWriteContentRef = useRef<{ path: string; hash: number } | null>(null) + const duplicateWriteCountRef = useRef(0) // Ref for per-session token accumulation (avoids stale closures in send()) const tokenUsageRef = useRef({ promptTokens: 0, completionTokens: 0, totalTokens: 0 }) - // Sync refs with props — skip when an override is active (plan execution in progress) useEffect(() => { if (planOverrideRef.current === null) { @@ -371,7 +387,7 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh }) // Wait for user response via Promise that will be resolved by the UI - const askOptions = call.params.options as Array<{ label: string; description?: string }> | undefined + const askOptions = call.params.options as Array<{ label: string; description?: string } | string> | undefined const answer = await new Promise((resolve) => { setState(s => ({ ...s, pendingAskUser: { question, options: askOptions, resolve } })) }) @@ -789,7 +805,7 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh const createAgentLLMClient = useCallback(( baseClient: AgentCompatibleLLMClient, chatRef: React.RefObject, - contextMessages?: Array<{ role: 'system'; content: string }> + contextMessages?: Array<{ role: 'system'; content: string }> | (() => Array<{ role: 'system'; content: string }>) ): AgentCompatibleLLMClient => { // Helper to restore overrides and clean up agent loop state const restoreOverridesAndCleanup = () => { @@ -806,7 +822,17 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh chatModeOverrideRef.current = null } lastFailedToolRef.current = null - setState(s => ({ ...s, isAgentLoopActive: false })) + agentLoopIterationRef.current = 0 + lastWriteContentRef.current = null + duplicateWriteCountRef.current = 0 + setState(s => ({ ...s, isAgentLoopActive: false, iteration: 0 })) + } + + // Get maxIterations from current mode config (default 15) + const getMaxIterations = (): number => { + const modes = chatModesRef.current + const modeConfig = modes?.[chatModeRef.current] + return modeConfig?.settings?.maxIterations ?? 15 } // Helper to process a parsed agent response @@ -834,28 +860,77 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh } } - // Check if done - if (parsed.done) { - console.log('[useAgentMode] Agent signaled done - resetting state') - restoreOverridesAndCleanup() + // Handle tool calls first — if the LLM returned tool calls, execute them + // regardless of the done flag (LLMs sometimes incorrectly set done=true + // alongside tool calls, which would short-circuit execution) + if (parsed.toolCalls.length > 0) { + agentLoopActiveRef.current = true + agentLoopRetryCountRef.current = 0 - return { - ...response, - content: parsed.message, - metadata: { - ...response.metadata, - suggestion: parsed.suggestion || null, - toolCalls: [], - done: true + // Enforce maxIterations — stop the loop before it runs away + agentLoopIterationRef.current++ + const maxIter = getMaxIterations() + setState(s => ({ ...s, isAgentLoopActive: true, iteration: agentLoopIterationRef.current })) + + if (agentLoopIterationRef.current > maxIter) { + console.log(`[useAgentMode] Max iterations reached (${agentLoopIterationRef.current}/${maxIter}) - stopping agent loop`) + restoreOverridesAndCleanup() + + const stopMsg = parsed.message + ? parsed.message + '\n\n*Reached maximum iterations. Stopping to avoid an infinite loop.*' + : '*Reached maximum iterations. Stopping to avoid an infinite loop.*' + + chatRef.current?.addMessage({ + id: `max_iter_${Date.now()}`, + role: 'assistant', + content: stopMsg, + timestamp: new Date().toISOString(), + metadata: { type: 'system-stop' } + }) + + return { + ...response, + content: stopMsg, + metadata: { ...response.metadata, done: true, messageAlreadyRendered: true } } } - } - // Handle tool calls - if (parsed.toolCalls.length > 0) { - agentLoopActiveRef.current = true - agentLoopRetryCountRef.current = 0 - setState(s => ({ ...s, isAgentLoopActive: true })) + // Detect duplicate write_file loops (same path + same content) + const writeCall = parsed.toolCalls.find(tc => tc.tool === 'write_file') + if (writeCall) { + const writePath = String(writeCall.params?.path || '') + const writeContent = String(writeCall.params?.content || '') + const contentHash = hashString(writeContent) + const last = lastWriteContentRef.current + + if (last && last.path === writePath && last.hash === contentHash) { + duplicateWriteCountRef.current++ + if (duplicateWriteCountRef.current >= MAX_DUPLICATE_WRITES) { + console.log(`[useAgentMode] Duplicate write detected ${duplicateWriteCountRef.current}x to "${writePath}" - stopping loop`) + restoreOverridesAndCleanup() + + const stopMsg = (parsed.message || '') + + '\n\n*Stopped: repeated identical writes detected. The same content was being written multiple times.*' + + chatRef.current?.addMessage({ + id: `dup_write_${Date.now()}`, + role: 'assistant', + content: stopMsg, + timestamp: new Date().toISOString(), + metadata: { type: 'system-stop' } + }) + + return { + ...response, + content: stopMsg, + metadata: { ...response.metadata, done: true, messageAlreadyRendered: true } + } + } + } else { + duplicateWriteCountRef.current = 0 + } + lastWriteContentRef.current = { path: writePath, hash: contentHash } + } // Add agent's message to chat BEFORE tool execution so it appears first in the UI if (parsed.message) { @@ -871,7 +946,7 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh } // Execute tool calls - console.log('[useAgentMode] Processing', parsed.toolCalls.length, 'tool calls') + console.log(`[useAgentMode] Processing ${parsed.toolCalls.length} tool calls (iteration ${agentLoopIterationRef.current}/${maxIter})`) const toolResults = await executeToolCalls(parsed.toolCalls, chatRef) // Check if any were rejected @@ -977,21 +1052,30 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh } } - // No tool calls - return as conversational response + // No tool calls — check if agent signaled done and clean up + if (parsed.done) { + console.log('[useAgentMode] Agent signaled done (no tool calls) - resetting state') + restoreOverridesAndCleanup() + } + return { ...response, content: parsed.message, metadata: { ...response.metadata, - suggestion: parsed.suggestion || null + suggestion: parsed.suggestion || null, + done: parsed.done || undefined } } } const send = async (request: PrompdLLMRequest): Promise => { - // Reset abort flag only for fresh user messages (not agent loop continuations) + // Reset abort flag and iteration counter for fresh user messages (not agent loop continuations) if (!agentLoopActiveRef.current) { abortRef.current = false + agentLoopIterationRef.current = 0 + lastWriteContentRef.current = null + duplicateWriteCountRef.current = 0 } // Check if abort was requested (by stop()) @@ -1020,8 +1104,12 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh } // Add any context messages (file content, etc.) - if (contextMessages) { - systemMessages.push(...contextMessages) + // When a getter function is provided, call it to get fresh content each iteration + const resolvedContext = typeof contextMessages === 'function' + ? contextMessages() + : contextMessages + if (resolvedContext && resolvedContext.length > 0) { + systemMessages.push(...resolvedContext) } // Combine with request messages @@ -1256,11 +1344,15 @@ export function useAgentMode(options: UseAgentModeOptions): [AgentState, AgentCh chatModeOverrideRef.current = null } lastFailedToolRef.current = null + agentLoopIterationRef.current = 0 + lastWriteContentRef.current = null + duplicateWriteCountRef.current = 0 setState(s => ({ ...s, isRunning: false, isPaused: false, isAgentLoopActive: false, + iteration: 0, pendingAskUser: null, pendingPlanApproval: null, pendingPlanReview: null diff --git a/frontend/src/modules/hooks/useInstalledSkills.ts b/frontend/src/modules/hooks/useInstalledSkills.ts new file mode 100644 index 0000000..6c5c165 --- /dev/null +++ b/frontend/src/modules/hooks/useInstalledSkills.ts @@ -0,0 +1,70 @@ +/** + * useInstalledSkills - Hook for discovering installed skill packages. + * + * Scans ~/.prompd/skills/ (global) and /.prompd/skills/ (local) + * via the skill:list IPC handler. Provides refresh() to force re-scan. + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import { useEditorStore } from '../../stores/editorStore' + +export interface InstalledSkill { + name: string + version: string + description?: string + tools?: string[] + main?: string + path: string + scope: 'workspace' | 'user' + parameters?: Record + allowedTools?: string[] +} + +interface UseInstalledSkillsResult { + skills: InstalledSkill[] + isLoading: boolean + error: string | null + refresh: () => void +} + +export function useInstalledSkills(): UseInstalledSkillsResult { + const [skills, setSkills] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const fetchIdRef = useRef(0) + const workspacePath = useEditorStore(state => state.explorerDirPath) + + const fetchSkills = useCallback(async () => { + const electronAPI = (window as unknown as Record).electronAPI as + { skill?: { list: (wp: string) => Promise<{ success: boolean; skills: InstalledSkill[]; error?: string }> } } | undefined + + if (!electronAPI?.skill) { + setSkills([]) + return + } + + const fetchId = ++fetchIdRef.current + setIsLoading(true) + setError(null) + + try { + const result = await electronAPI.skill.list(workspacePath || '') + if (fetchId !== fetchIdRef.current) return + + if (result.success) { + setSkills(result.skills || []) + } else { + setError(result.error || 'Failed to list skills') + } + } catch (err) { + if (fetchId !== fetchIdRef.current) return + setError(err instanceof Error ? err.message : 'Failed to list skills') + } finally { + if (fetchId === fetchIdRef.current) setIsLoading(false) + } + }, [workspacePath]) + + useEffect(() => { fetchSkills() }, [fetchSkills]) + + return { skills, isLoading, error, refresh: fetchSkills } +} diff --git a/frontend/src/modules/hooks/useTabManager.ts b/frontend/src/modules/hooks/useTabManager.ts index ad09348..5eb4aa1 100644 --- a/frontend/src/modules/hooks/useTabManager.ts +++ b/frontend/src/modules/hooks/useTabManager.ts @@ -43,7 +43,7 @@ export interface TabManager { closeOtherTabs: (tabId: string) => void // File sync operations - handleFileRenamed: (oldPath: string, newPath: string, newHandle?: FileSystemFileHandle) => void + handleFileRenamed: (oldPath: string, newPath: string, newFilePath?: string, newHandle?: FileSystemFileHandle) => void handleFileDeleted: (filePath: string) => void handleFilesRefreshed: (entries: { name: string; path: string; handle?: FileSystemFileHandle }[]) => void @@ -302,6 +302,23 @@ export function useTabManager(): TabManager { } } + // Clean up Monaco model for brainstorm tabs to prevent stale markers + if (closingTab.type === 'brainstorm') { + try { + const monacoInstance = (window as unknown as Record).monaco as typeof import('monaco-editor') | undefined + if (monacoInstance?.editor) { + for (const model of monacoInstance.editor.getModels()) { + if (model.uri.toString().includes(tabId)) { + monacoInstance.editor.setModelMarkers(model, 'prompd', []) + model.dispose() + } + } + } + } catch { + // Monaco not available — ignore + } + } + // Remove the tab removeTab(tabId) }, [removeTab, activateTab, setMode, setText]) @@ -344,6 +361,7 @@ export function useTabManager(): TabManager { const handleFileRenamed = useCallback(( oldPath: string, newPath: string, + newFilePath?: string, newHandle?: FileSystemFileHandle ): void => { console.log('[TabManager] File renamed:', oldPath, '->', newPath) @@ -356,8 +374,11 @@ export function useTabManager(): TabManager { return } - // Update tab with new path and handle + // Update tab with new name, filePath, and handle const updates: Partial = { name: newPath } + if (newFilePath) { + updates.filePath = newFilePath + } if (newHandle) { updates.handle = newHandle } diff --git a/frontend/src/modules/lib/intellisense/codeActions.ts b/frontend/src/modules/lib/intellisense/codeActions.ts index 75920c8..d4c9fd6 100644 --- a/frontend/src/modules/lib/intellisense/codeActions.ts +++ b/frontend/src/modules/lib/intellisense/codeActions.ts @@ -3,6 +3,7 @@ */ import type * as monacoEditor from 'monaco-editor' import { fixObjectParamsToArray } from './utils' +import { getCurrentFilePath, setCurrentFilePath } from './validation' import { logger } from '../logger' // Scoped logger for code actions (can be disabled in production) @@ -21,6 +22,8 @@ export const CODE_ACTION_IDS = { ADD_FRONTMATTER: 'prompd.add-frontmatter', ADD_REQUIRED_FIELD: 'prompd.add-required-field', CONVERT_TO_KEBAB_CASE: 'prompd.convert-to-kebab-case', + FIX_ID_TO_MATCH_FILENAME: 'prompd.fix-id-to-match-filename', + RENAME_FILE_TO_MATCH_ID: 'prompd.rename-file-to-match-id', ADD_PACKAGE_VERSION: 'prompd.add-package-version', // Parameter fixes DEFINE_PARAMETER: 'prompd.define-parameter', @@ -299,6 +302,49 @@ export function registerCodeActionProvider( ): monacoEditor.IDisposable { log.log('Registering code action provider for language:', languageId) + // Register command: rename file to match id + // Guard against duplicate fires (code actions can trigger multiple times) + let renameInProgress = false + monaco.editor.registerCommand(CODE_ACTION_IDS.RENAME_FILE_TO_MATCH_ID, async (_accessor, filePath: string, newId: string) => { + if (renameInProgress) return + renameInProgress = true + try { + const electronAPI = (window as { electronAPI?: { + rename: (oldPath: string, newPath: string) => Promise<{ success: boolean; error?: string }> + readFile: (path: string) => Promise<{ success: boolean; content?: string }> + } }).electronAPI + if (!electronAPI?.rename) { + log.log('Electron rename API not available') + return + } + const normalized = filePath.replace(/\\/g, '/') + const dir = normalized.substring(0, normalized.lastIndexOf('/')) + const newPath = `${dir}/${newId}.prmd` + const newFileName = `${newId}.prmd` + log.log('Renaming file:', normalized, '->', newPath) + + // Dispatch rename event BEFORE the disk rename so TabManager updates + // the tab before the file watcher can invalidate it + window.dispatchEvent(new CustomEvent('prompd-file-renamed', { + detail: { oldPath: normalized, newPath, newFileName } + })) + + const result = await electronAPI.rename(normalized, newPath) + if (!result.success) { + log.log('Rename failed:', result.error) + // Revert: dispatch back so tab name is restored + window.dispatchEvent(new CustomEvent('prompd-file-renamed', { + detail: { oldPath: newPath, newPath: normalized } + })) + } else { + // Update intellisense file path to the new location + setCurrentFilePath(newPath) + } + } finally { + renameInProgress = false + } + }) + return monaco.languages.registerCodeActionProvider( languageId, { @@ -568,6 +614,55 @@ export function registerCodeActionProvider( } } + // Quick-fix: ID/filename mismatch — offer both directions + if (marker.code === 'id-filename-mismatch') { + // Extract id and filename from message: "ID 'xxx' does not match filename 'yyy'." + const mismatchMatch = marker.message.match(/ID '([^']+)' does not match filename '([^']+)'/) + if (mismatchMatch) { + const currentId = mismatchMatch[1] + const fileBaseName = mismatchMatch[2] + + // Option 1: Rename id to match filename (text edit) + actions.push({ + title: `Change id to '${fileBaseName}'`, + kind: 'quickfix', + diagnostics: [marker], + isPreferred: true, + edit: { + edits: [{ + resource: model.uri, + textEdit: { + range: { + startLineNumber: marker.startLineNumber, + startColumn: marker.startColumn, + endLineNumber: marker.endLineNumber, + endColumn: marker.endColumn + }, + text: fileBaseName + }, + versionId: model.getVersionId() + }] + } + }) + + // Option 2: Rename file to match id (command) + const filePath = getCurrentFilePath() + if (filePath) { + actions.push({ + title: `Rename file to '${currentId}.prmd'`, + kind: 'quickfix', + diagnostics: [marker], + isPreferred: false, + command: { + id: CODE_ACTION_IDS.RENAME_FILE_TO_MATCH_ID, + title: `Rename file to '${currentId}.prmd'`, + arguments: [filePath, currentId] + } + }) + } + } + } + // Quick-fix: Add version to package reference if (marker.message.includes('should include version')) { const pkgMatch = marker.message.match(/Package reference '([^']+)'/) @@ -598,17 +693,37 @@ export function registerCodeActionProvider( } // Quick-fix: Define undefined parameter - if (marker.message.includes('Undefined parameter')) { - const paramMatch = marker.message.match(/Undefined parameter '\{(\w+)\}'/) + // Matches both validation.ts ("Undefined parameter '{foo}'") and + // crossReference.ts ("Parameter 'foo' is not defined") message formats + if ( + marker.message.includes('Undefined parameter') || + marker.message.includes('is not defined') || + (marker as { code?: string }).code === 'undefined-parameter' + ) { + // Extract parameter name from either message format + const paramMatch = marker.message.match(/Undefined parameter '\{(\w+)\}'/) || + marker.message.match(/Parameter '(\w+)' is not defined/) if (paramMatch) { const paramName = paramMatch[1] + const lines = content.split('\n') - // Find parameters section or create one - const paramsMatch = content.match(/^(\s*)parameters:\s*$/m) + // Find the parameters section and determine where to insert + const paramsSectionLine = lines.findIndex(l => /^\s*parameters:\s*$/.test(l)) + + if (paramsSectionLine >= 0) { + // Find the end of the parameters block (last line that's indented under parameters:) + let insertAfterLine = paramsSectionLine + for (let i = paramsSectionLine + 1; i < lines.length; i++) { + const line = lines[i] + // Stop at blank lines, non-indented lines, or frontmatter end + if (line.trim() === '---' || (line.trim() !== '' && !line.startsWith(' ') && !line.startsWith('\t'))) { + break + } + if (line.trim() !== '') { + insertAfterLine = i + } + } - if (paramsMatch) { - // Add to existing parameters section - const paramLine = content.split('\n').findIndex(l => l.match(/^\s*parameters:\s*$/)) + 1 actions.push({ title: `Define parameter '${paramName}'`, kind: 'quickfix', @@ -618,16 +733,16 @@ export function registerCodeActionProvider( edits: [{ resource: model.uri, textEdit: { - range: { startLineNumber: paramLine + 1, startColumn: 1, endLineNumber: paramLine + 1, endColumn: 1 }, - text: ` - name: ${paramName}\n type: string\n description: "TODO: Add description"\n required: true\n` + range: { startLineNumber: insertAfterLine + 2, startColumn: 1, endLineNumber: insertAfterLine + 2, endColumn: 1 }, + text: ` - name: ${paramName}\n type: string\n description: ""\n` }, versionId: model.getVersionId() }] } }) } else { - // Create parameters section - const frontmatterEnd = content.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + // No parameters section — create one before the closing --- + const frontmatterEnd = lines.findIndex((l, i) => i > 0 && l.trim() === '---') if (frontmatterEnd > 0) { actions.push({ title: `Define parameter '${paramName}'`, @@ -638,8 +753,8 @@ export function registerCodeActionProvider( edits: [{ resource: model.uri, textEdit: { - range: { startLineNumber: frontmatterEnd, startColumn: 1, endLineNumber: frontmatterEnd, endColumn: 1 }, - text: `parameters:\n - name: ${paramName}\n type: string\n description: "TODO: Add description"\n required: true\n` + range: { startLineNumber: frontmatterEnd + 1, startColumn: 1, endLineNumber: frontmatterEnd + 1, endColumn: 1 }, + text: `parameters:\n - name: ${paramName}\n type: string\n description: ""\n` }, versionId: model.getVersionId() }] @@ -650,7 +765,7 @@ export function registerCodeActionProvider( // Also offer to remove the undefined reference actions.push({ - title: `Remove undefined reference '{${paramName}}'`, + title: `Remove reference '${paramName}'`, kind: 'quickfix', diagnostics: [marker], edit: { diff --git a/frontend/src/modules/lib/intellisense/crossReference.ts b/frontend/src/modules/lib/intellisense/crossReference.ts index a74fe4b..29b1dab 100644 --- a/frontend/src/modules/lib/intellisense/crossReference.ts +++ b/frontend/src/modules/lib/intellisense/crossReference.ts @@ -64,7 +64,8 @@ export function extractParameterDefinitions( } // Check if we've left parameters section (new key at same or lower indent) - if (inParametersSection && line.trim() !== '') { + // Skip comment lines — they shouldn't trigger section exit + if (inParametersSection && line.trim() !== '' && !line.trim().startsWith('#')) { const currentIndent = line.search(/\S/) if (currentIndent !== -1 && currentIndent <= parametersIndent && !line.match(/^\s*-/)) { inParametersSection = false @@ -72,6 +73,9 @@ export function extractParameterDefinitions( } if (inParametersSection) { + // Skip comment lines inside parameters section + if (line.trim().startsWith('#')) continue + const currentIndent = line.search(/\S/) // Set parameter level indent on first parameter encountered @@ -80,8 +84,10 @@ export function extractParameterDefinitions( } // Array format: " - name: paramName" + // Only match at the parameter level indent to avoid matching nested + // "- name:" entries inside complex default values const arrayMatch = line.match(/^\s*-\s*name:\s*["']?(\w+)["']?/) - if (arrayMatch) { + if (arrayMatch && (parameterLevelIndent === -1 || currentIndent === parameterLevelIndent)) { const name = arrayMatch[1] const lineNumber = fullContent.split('\n').findIndex(l => l.includes(line)) + 1 const column = line.indexOf(name) + 1 @@ -164,6 +170,82 @@ export function extractParameterDefinitions( return definitions } +/** + * Extract parameter references from YAML frontmatter string values. + * Fields like system:, user:, task: can contain {{ param }} template content. + */ +function extractFrontmatterParameterReferences( + yamlContent: string, + fullContent: string +): ParameterReference[] { + const references: ParameterReference[] = [] + + // Parse YAML to find string values containing {{ param }} references + try { + const parsed = parseYAML(yamlContent) + if (!parsed || typeof parsed !== 'object') return references + + // Collect all string values from the YAML (excluding the parameters section itself) + const stringValues: Array<{ value: string; key: string }> = [] + + const collectStrings = (obj: Record, parentKey = '') => { + for (const [key, value] of Object.entries(obj)) { + if (key === 'parameters') continue // Skip parameter definitions + const fullKey = parentKey ? `${parentKey}.${key}` : key + if (typeof value === 'string') { + stringValues.push({ value, key: fullKey }) + } else if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'string') { + stringValues.push({ value: item, key: fullKey }) + } else if (item && typeof item === 'object') { + collectStrings(item as Record, fullKey) + } + } + } else if (value && typeof value === 'object') { + collectStrings(value as Record, fullKey) + } + } + } + collectStrings(parsed) + + // Scan each string value for {{ param }} references + const templateVarRegex = /\{\{\s*(\w+(?:\.\w+)*)\s*(?:\|[^}]*)?\}\}/g + for (const { value } of stringValues) { + let match: RegExpExecArray | null + while ((match = templateVarRegex.exec(value)) !== null) { + const fullRef = match[1] + const parts = fullRef.split('.') + const rootVar = parts[0] + + // Find approximate line number in the full content + const lineNumber = fullContent.split('\n').findIndex(l => l.includes(match![0])) + 1 + + references.push({ + name: rootVar, + lineNumber: lineNumber > 0 ? lineNumber : 1, + column: 1, + context: `frontmatter: ${value.substring(0, 60)}` + }) + + // workflow.paramName proxies to the parameter + if (rootVar === 'workflow' && parts.length > 1) { + references.push({ + name: parts[1], + lineNumber: lineNumber > 0 ? lineNumber : 1, + column: 1, + context: `frontmatter: ${value.substring(0, 60)}` + }) + } + } + } + } catch { + // YAML parse error — skip frontmatter scanning + } + + return references +} + /** * Extract parameter references from body content */ @@ -209,7 +291,8 @@ export function extractParameterReferences( // {% for item in collection %} - collection is used (item is defined by loop) // {% set var = expression %} - variables in expression are used (var is defined) // {% if condition %} - variables in condition are used - const forLoopRegex = /\{%-?\s*for\s+(\w+)\s+in\s+\[?\s*(\w+(?:\.\w+)*)\s*\]?/g + // Matches single var and tuple unpacking: {% for key, value in dict %} + const forLoopRegex = /\{%-?\s*for\s+[\w,\s]+?\s+in\s+\[?\s*(\w+(?:\.\w+)*)\s*\]?/g const setVarRegex = /\{%-?\s*set\s+\w+\s*=\s*(.+?)\s*%\}/g const ifRegex = /\{%-?\s*(?:if|elif)\s+(.+?)\s*%\}/g @@ -219,7 +302,7 @@ export function extractParameterReferences( // Find for loops - extract the COLLECTION being iterated over (not the loop variable) let forMatch: RegExpExecArray | null while ((forMatch = forLoopRegex.exec(line)) !== null) { - const collection = forMatch[2] // e.g., "items" or "stakeholders" + const collection = forMatch[1] // e.g., "items" or "stakeholders" const parts = collection.split('.') const rootVar = parts[0] // Handle nested like "config.items" @@ -285,7 +368,9 @@ export function extractParameterReferences( // Find all variable references in the condition // Use negative lookbehind (? 0 + + // Also scan YAML frontmatter string values for {{ param }} references + // Fields like system:, user:, task: can contain template content + const yamlParamRefs = extractFrontmatterParameterReferences(yamlContent, document) + // Extract definitions and references const definitions = extractParameterDefinitions(yamlContent, document) - const references = extractParameterReferences(bodyContent, bodyStartLine) + const bodyReferences = extractParameterReferences(bodyContent, bodyStartLine) + const references = [...bodyReferences, ...yamlParamRefs] - // Create sets for quick lookup + // Build the full set of known parameters: local + inherited const definedParams = new Set(definitions.map(d => d.name)) + const inheritedParams = new Set(inheritedDefinitions?.map(d => d.name) ?? []) + const allKnownParams = new Set([...definedParams, ...inheritedParams]) const referencedParams = new Set(references.map(r => r.name)) // Built-in variables that shouldn't be flagged as undefined @@ -349,46 +450,49 @@ export function analyzeParameterUsage( // Template-defined variables (from {% set %} and {% for %}) const templateDefined = new Set() const setVarPattern = /\{%-?\s*set\s+(\w+)\s*=/g - const forLoopPattern = /\{%-?\s*for\s+(\w+)\s+in\s+/g + // Matches both single var: {% for item in list %} and tuple unpacking: {% for key, value in dict %} + const forLoopPattern = /\{%-?\s*for\s+([\w,\s]+?)\s+in\s+/g for (const match of bodyContent.matchAll(setVarPattern)) { templateDefined.add(match[1]) } for (const match of bodyContent.matchAll(forLoopPattern)) { - templateDefined.add(match[1]) + // Split on comma to handle tuple unpacking (e.g., "service, owner") + const vars = match[1].split(',').map(v => v.trim()).filter(Boolean) + for (const v of vars) { + templateDefined.add(v) + } templateDefined.add('loop') // Loop helpers } // Find unused parameters (defined but never referenced) - for (const def of definitions) { - // Check if parameter is used in body - const isUsed = references.some(ref => ref.name === def.name) - - if (!isUsed) { - diagnostics.push({ - severity: monaco.MarkerSeverity.Hint, - startLineNumber: def.lineNumber, - startColumn: def.column, - endLineNumber: def.lineNumber, - endColumn: def.column + def.name.length, - message: `Parameter '${def.name}' is defined but never used in the prompt body`, - code: 'unused-parameter', - tags: [monaco.MarkerTag.Unnecessary] - }) + // Skip when file inherits — child params are passed to the parent template + // during compilation and we can't validate usage without the full chain + if (!hasInherits) { + for (const def of definitions) { + const isUsed = references.some(ref => ref.name === def.name) + + if (!isUsed) { + diagnostics.push({ + severity: monaco.MarkerSeverity.Hint, + startLineNumber: def.lineNumber, + startColumn: def.column, + endLineNumber: def.lineNumber, + endColumn: def.column + def.name.length, + message: `Parameter '${def.name}' is defined but never used in the prompt body`, + code: 'unused-parameter', + tags: [monaco.MarkerTag.Unnecessary] + }) + } } } - // Find missing parameters (referenced but not defined) + // Find missing parameters (referenced but not defined — locally or inherited) const seenUndefined = new Set() for (const ref of references) { - // Skip if: - // - Already defined in parameters - // - Built-in variable - // - Template-defined variable ({% set %} or {% for %}) - // - Already reported if ( - definedParams.has(ref.name) || + allKnownParams.has(ref.name) || builtInVars.has(ref.name) || templateDefined.has(ref.name) || seenUndefined.has(ref.name) @@ -398,15 +502,33 @@ export function analyzeParameterUsage( seenUndefined.add(ref.name) - diagnostics.push({ - severity: monaco.MarkerSeverity.Error, - startLineNumber: ref.lineNumber, - startColumn: ref.column, - endLineNumber: ref.lineNumber, - endColumn: ref.column + ref.name.length + 4, // Include {{ }} - message: `Parameter '${ref.name}' is not defined in frontmatter parameters section. Add it to the parameters list.`, - code: 'undefined-parameter' - }) + if (hasInherits && !hasResolvedInherited) { + // Inherits but we couldn't resolve the parent file — suppress entirely. + // We can't verify whether the param exists in the parent, so trust the + // inheritance declaration rather than showing noisy false-positive hints. + continue + } else if (hasInherits && hasResolvedInherited) { + // We resolved the parent but this param isn't in local or inherited + diagnostics.push({ + severity: monaco.MarkerSeverity.Warning, + startLineNumber: ref.lineNumber, + startColumn: ref.column, + endLineNumber: ref.lineNumber, + endColumn: ref.column + ref.name.length + 4, + message: `Parameter '${ref.name}' is not defined locally or in inherited '${inheritsMatch![1]}'.`, + code: 'undefined-parameter' + }) + } else { + diagnostics.push({ + severity: monaco.MarkerSeverity.Warning, + startLineNumber: ref.lineNumber, + startColumn: ref.column, + endLineNumber: ref.lineNumber, + endColumn: ref.column + ref.name.length + 4, + message: `Parameter '${ref.name}' is not defined in frontmatter parameters section. Add it to the parameters list.`, + code: 'undefined-parameter' + }) + } } return diagnostics @@ -440,8 +562,10 @@ export function getParameterUsageStats(document: string): { const bodyContent = bodyMatch ? bodyMatch[1] : '' const bodyStartLine = document.substring(0, document.indexOf(bodyContent)).split('\n').length + const yamlParamRefs = extractFrontmatterParameterReferences(yamlContent, document) const definitions = extractParameterDefinitions(yamlContent, document) - const references = extractParameterReferences(bodyContent, bodyStartLine) + const bodyReferences = extractParameterReferences(bodyContent, bodyStartLine) + const references = [...bodyReferences, ...yamlParamRefs] const definedParams = new Set(definitions.map(d => d.name)) const referencedParams = new Set(references.map(r => r.name)) diff --git a/frontend/src/modules/lib/intellisense/hover.ts b/frontend/src/modules/lib/intellisense/hover.ts index 6778341..fce076d 100644 --- a/frontend/src/modules/lib/intellisense/hover.ts +++ b/frontend/src/modules/lib/intellisense/hover.ts @@ -345,6 +345,14 @@ export function registerHoverProvider( packageVersion = rest } resolvedPackage = `${packageName}@${packageVersion}` + } else { + // No version specifier: @scope/name/path/to/file.prmd + const parts = inheritsRef.split('/') + if (parts.length >= 3) { + packageName = `${parts[0]}/${parts[1]}` // @scope/name + subPath = parts.slice(2).join('/') + resolvedPackage = packageName + } } } // Local relative path @@ -364,21 +372,74 @@ export function registerHoverProvider( const ns = packageName.substring(1, nsSlash) // strip @ const name = packageName.substring(nsSlash + 1) - // Try workspace .prompd/cache/ first (where compiler installs packages) + const electronAPI = (window as unknown as Record).electronAPI as { + readFile?: (p: string) => Promise<{ success: boolean; content?: string }> + readDir?: (p: string) => Promise<{ success: boolean; files?: { name: string; isDirectory: boolean }[] }> + getHomePath?: () => Promise + } | undefined + + // Helper: try to find file inside a cache base, scanning for versions if needed + const tryResolveInDir = async (cacheBase: string, sep: string): Promise => { + const pkgDir = [cacheBase, `@${ns}`, name].join(sep) + + // If we have a specific version, try that directly + if (packageVersion) { + const filePath = [pkgDir, packageVersion, subPath].join(sep) + if (electronAPI?.readFile) { + const check = await electronAPI.readFile(filePath) + if (check.success) return filePath + } + } + + // No version or version not found — scan for available versions + if (electronAPI?.readDir) { + try { + const result = await electronAPI.readDir(pkgDir) + if (result.success && result.files) { + const versionDirs = result.files + .filter(e => e.isDirectory) + .map(e => e.name) + .sort() + .reverse() + + for (const ver of versionDirs) { + const filePath = [pkgDir, ver, subPath].join(sep) + if (electronAPI.readFile) { + const check = await electronAPI.readFile(filePath) + if (check.success) { + // Update packageVersion for display in hover tooltip + if (!packageVersion) packageVersion = ver + return filePath + } + } + } + } + } catch { + // Directory doesn't exist + } + } + + return null + } + + // Try workspace .prompd/cache/ and .prompd/packages/ const wsPath = getWorkspacePath() if (wsPath) { const sep = wsPath.includes('\\') ? '\\' : '/' - resolvedFilePath = [wsPath, '.prompd', 'cache', `@${ns}`, name, packageVersion, subPath].join(sep) + resolvedFilePath = await tryResolveInDir([wsPath, '.prompd', 'cache'].join(sep), sep) + if (!resolvedFilePath) { + resolvedFilePath = await tryResolveInDir([wsPath, '.prompd', 'packages'].join(sep), sep) + } } - // Fall back to global ~/.prompd/cache/ - if (!resolvedFilePath) { + // Fall back to global ~/.prompd/cache/ and ~/.prompd/packages/ + if (!resolvedFilePath && electronAPI?.getHomePath) { try { - const electronAPI = (window as { electronAPI?: { getHomePath: () => Promise } }).electronAPI - if (electronAPI) { - const homePath = await electronAPI.getHomePath() - const sep = homePath.includes('\\') ? '\\' : '/' - resolvedFilePath = [homePath, '.prompd', 'cache', `@${ns}`, name, packageVersion, subPath].join(sep) + const homePath = await electronAPI.getHomePath() + const sep = homePath.includes('\\') ? '\\' : '/' + resolvedFilePath = await tryResolveInDir([homePath, '.prompd', 'cache'].join(sep), sep) + if (!resolvedFilePath) { + resolvedFilePath = await tryResolveInDir([homePath, '.prompd', 'packages'].join(sep), sep) } } catch { // Can't resolve home path diff --git a/frontend/src/modules/lib/intellisense/utils.ts b/frontend/src/modules/lib/intellisense/utils.ts index 97703c4..25d781e 100644 --- a/frontend/src/modules/lib/intellisense/utils.ts +++ b/frontend/src/modules/lib/intellisense/utils.ts @@ -60,14 +60,17 @@ export function extractParametersWithMetadata(content: string): ExtractedParamet } // Extract loop variables from {% for VAR in COLLECTION %} or {%- for VAR in COLLECTION %} blocks (Nunjucks/Jinja2 syntax) - // This handles patterns like: {% for record in csv_data %} or {%- for record in csv_data %} -> record is a valid variable + // Also handles tuple unpacking: {% for key, value in dict %} -> both key and value are valid variables // The hyphen is for whitespace trimming - const forLoopMatches = Array.from(content.matchAll(/\{%-?\s*for\s+(\w+)\s+in\s+(\w+)/g)) + const forLoopMatches = Array.from(content.matchAll(/\{%-?\s*for\s+([\w,\s]+?)\s+in\s+(\w+)/g)) for (const match of forLoopMatches) { - const loopVar = match[1] - loopVariables.add(loopVar) - if (!parameters.includes(loopVar)) { - parameters.push(loopVar) + // Split on comma to handle tuple unpacking (e.g., "service, owner") + const vars = match[1].split(',').map(v => v.trim()).filter(Boolean) + for (const loopVar of vars) { + loopVariables.add(loopVar) + if (!parameters.includes(loopVar)) { + parameters.push(loopVar) + } } } diff --git a/frontend/src/modules/lib/intellisense/validation.ts b/frontend/src/modules/lib/intellisense/validation.ts index c717540..410f691 100644 --- a/frontend/src/modules/lib/intellisense/validation.ts +++ b/frontend/src/modules/lib/intellisense/validation.ts @@ -5,7 +5,8 @@ import type * as monacoEditor from 'monaco-editor' import { parse as parseYAML } from 'yaml' import type { CompilationDiagnostic } from '../../../electron.d' import { getRegistrySync } from './registrySync' -import { analyzeParameterUsage } from './crossReference' +import { analyzeParameterUsage, extractParameterDefinitions } from './crossReference' +import type { ParameterDefinition } from './crossReference' const LANGUAGE_ID = 'prompd' @@ -57,6 +58,201 @@ export function enableCompilerDiagnostics() { console.log('[intellisense] Compiler diagnostics enabled') } +/** + * Resolve inherited parameter definitions from the parent .prmd file. + * Handles three path formats: + * 1. Relative paths: ./base.prmd, ../shared/base.prmd + * 2. Direct package refs: @namespace/package@version/path/to/file.prmd + * 3. Prefix aliases: @core/prompts/base.prmd (resolved via using: section) + */ +async function resolveInheritedParameters( + yamlContent: string +): Promise { + const electronAPI = (window as unknown as Record).electronAPI as { + isElectron?: boolean + readFile?: (path: string) => Promise<{ success: boolean; content?: string; error?: string }> + readDir?: (path: string) => Promise<{ success: boolean; files?: { name: string; isDirectory: boolean }[] }> + getHomePath?: () => Promise + } | undefined + + if (!electronAPI?.readFile) return [] + + // Extract inherits: value + const inheritsMatch = yamlContent.match(/^\s*inherits:\s*["']?(.+?)["']?\s*$/m) + if (!inheritsMatch) return [] + const inheritsRef = inheritsMatch[1].trim() + + // Build prefix map from using: section + const prefixMap = new Map() + const usingEntries = Array.from(yamlContent.matchAll( + /(?:^|\n)\s*-\s*(?:name:\s*["']?(@[\w./@^~*-]+)["']?\s+prefix:\s*["']?(@[\w-]+)["']?|prefix:\s*["']?(@[\w-]+)["']?\s+name:\s*["']?(@[\w./@^~*-]+)["']?)/g + )) + for (const entry of usingEntries) { + const pkg = entry[1] || entry[4] + const prefix = entry[2] || entry[3] + if (pkg && prefix) { + prefixMap.set(prefix, pkg) + } + } + + let resolvedFilePath: string | null = null + + // 1. Prefix alias reference (e.g., @core/prompts/base.prmd) + const prefixMatch = inheritsRef.match(/^(@[\w-]+)\/(.+)$/) + if (prefixMatch && prefixMap.has(prefixMatch[1])) { + const pkgRef = prefixMap.get(prefixMatch[1])! + const subPath = prefixMatch[2] + resolvedFilePath = await resolvePackagePath(pkgRef, subPath, electronAPI) + } + // 2. Direct package reference with version (e.g., @namespace/package@version/path.prmd) + else if (inheritsRef.startsWith('@') && !prefixMap.has(inheritsRef.split('/')[0])) { + const versionAt = inheritsRef.indexOf('@', 1) + if (versionAt > 0) { + const packageRef = inheritsRef.substring(0, versionAt) + const rest = inheritsRef.substring(versionAt + 1) + const slashIdx = rest.indexOf('/') + const version = slashIdx >= 0 ? rest.substring(0, slashIdx) : rest + const subPath = slashIdx >= 0 ? rest.substring(slashIdx + 1) : '' + const fullRef = `${packageRef}@${version}` + resolvedFilePath = await resolvePackagePath(fullRef, subPath, electronAPI) + } else { + // No version specifier: @scope/name/path/to/file.prmd + // Parse as @scope/name package with remaining path as subpath + const parts = inheritsRef.split('/') + if (parts.length >= 3) { + const packageName = `${parts[0]}/${parts[1]}` // @scope/name + const subPath = parts.slice(2).join('/') + resolvedFilePath = await resolvePackagePath(packageName, subPath, electronAPI) + } + } + } + // 3. Relative path (e.g., ./base.prmd, ../shared/base.prmd) + else if (currentFilePath) { + const sep = currentFilePath.includes('\\') ? '\\' : '/' + const dir = currentFilePath.substring(0, currentFilePath.lastIndexOf(sep)) + resolvedFilePath = `${dir}${sep}${inheritsRef.replace(/\//g, sep)}` + } + + if (!resolvedFilePath) return [] + + // Read the parent file and extract its parameter definitions + try { + const result = await electronAPI.readFile(resolvedFilePath) + if (!result.success || !result.content) return [] + + const normalized = result.content.replace(/\r\n/g, '\n') + const fmMatch = normalized.match(/^---\n([\s\S]*?)\n---/) + if (!fmMatch) return [] + + return extractParameterDefinitions(fmMatch[1], normalized) + } catch { + return [] + } +} + +/** + * Resolve a package reference to a file path in the .prompd/cache/ directory. + * Checks workspace cache first, then global ~/.prompd/cache/. + */ +async function resolvePackagePath( + pkgRef: string, + subPath: string, + electronAPI: { + readFile?: (path: string) => Promise<{ success: boolean; content?: string; error?: string }> + readDir?: (path: string) => Promise<{ success: boolean; files?: { name: string; isDirectory: boolean }[] }> + getHomePath?: () => Promise + } +): Promise { + // Parse @namespace/name@version + const versionAt = pkgRef.lastIndexOf('@') + let packageName: string + let packageVersion: string + if (versionAt > 0 && pkgRef[0] === '@') { + packageName = pkgRef.substring(0, versionAt) + packageVersion = pkgRef.substring(versionAt + 1) + } else { + packageName = pkgRef + packageVersion = '' + } + + const nsSlash = packageName.indexOf('/') + if (nsSlash < 0) return null + const ns = packageName.substring(1, nsSlash) // strip @ + const name = packageName.substring(nsSlash + 1) + + // Helper: try to find a file inside a cache base path (e.g., .prompd/cache/@ns/name) + const tryResolveInCache = async (cacheBase: string, sep: string): Promise => { + const pkgDir = [cacheBase, `@${ns}`, name].join(sep) + + if (packageVersion) { + // Specific version requested — try directly + const filePath = [pkgDir, packageVersion, subPath].join(sep) + if (electronAPI.readFile) { + const check = await electronAPI.readFile(filePath) + if (check.success) return filePath + } + } + + // No version or version not found — scan for available versions + if (electronAPI.readDir) { + try { + const result = await electronAPI.readDir(pkgDir) + if (!result.success || !result.files) return null + const versionDirs = result.files + .filter(e => e.isDirectory) + .map(e => e.name) + .sort() + .reverse() // latest version first (lexicographic approximation) + + for (const ver of versionDirs) { + const filePath = [pkgDir, ver, subPath].join(sep) + if (electronAPI.readFile) { + const check = await electronAPI.readFile(filePath) + if (check.success) return filePath + } + } + } catch { + // Directory doesn't exist or can't be read + } + } + + return null + } + + // Try workspace .prompd/cache/ first + if (currentWorkspacePath) { + const sep = currentWorkspacePath.includes('\\') ? '\\' : '/' + const wsCache = [currentWorkspacePath, '.prompd', 'cache'].join(sep) + const result = await tryResolveInCache(wsCache, sep) + if (result) return result + + // Also try .prompd/packages/ (installed packages directory) + const wsPkgs = [currentWorkspacePath, '.prompd', 'packages'].join(sep) + const pkgResult = await tryResolveInCache(wsPkgs, sep) + if (pkgResult) return pkgResult + } + + // Fall back to global ~/.prompd/cache/ and ~/.prompd/packages/ + try { + if (electronAPI.getHomePath) { + const homePath = await electronAPI.getHomePath() + const sep = homePath.includes('\\') ? '\\' : '/' + + const globalCache = [homePath, '.prompd', 'cache'].join(sep) + const result = await tryResolveInCache(globalCache, sep) + if (result) return result + + const globalPkgs = [homePath, '.prompd', 'packages'].join(sep) + const pkgResult = await tryResolveInCache(globalPkgs, sep) + if (pkgResult) return pkgResult + } + } catch { + // Can't resolve home path + } + + return null +} + /** * Common YAML error patterns and their user-friendly messages */ @@ -518,7 +714,7 @@ function validateJsonSyntax( } markers.push({ - severity: monaco.MarkerSeverity.Error, + severity: monaco.MarkerSeverity.Warning, startLineNumber: errorLine, startColumn: errorColumn, endLineNumber: errorLine, @@ -1051,7 +1247,8 @@ async function fetchCompilerDiagnostics(content: string): Promise() @@ -1222,6 +1452,7 @@ export async function validateModel( const lines = yamlContent.split(/\r?\n/) let inParametersSection = false let parametersIndent = -1 + let parameterLevelIndent = -1 for (let i = 0; i < lines.length; i++) { const line = lines[i] @@ -1230,12 +1461,14 @@ export async function validateModel( if (line.match(/^\s*parameters:\s*$/)) { inParametersSection = true parametersIndent = line.search(/\S/) + parameterLevelIndent = -1 console.log('[intellisense] Found parameters section at indent', parametersIndent) continue } // Check if we've left parameters section (new key at same or lower indent) - if (inParametersSection && line.trim() !== '') { + // Skip comment lines — they shouldn't trigger section exit + if (inParametersSection && line.trim() !== '' && !line.trim().startsWith('#')) { const currentIndent = line.search(/\S/) if (currentIndent !== -1 && currentIndent <= parametersIndent && !line.match(/^\s*-/)) { inParametersSection = false @@ -1244,28 +1477,43 @@ export async function validateModel( } if (inParametersSection) { + // Skip comment lines inside parameters section + if (line.trim().startsWith('#')) continue + + const currentIndent = line.search(/\S/) + + // Set parameter level indent on first parameter encountered + if (parameterLevelIndent === -1 && currentIndent > parametersIndent && line.trim() !== '') { + parameterLevelIndent = currentIndent + } + // Array format: " - name: paramName" + // Only match at the parameter level indent to avoid matching nested + // "- name:" entries inside complex default values const arrayMatch = line.match(/^\s*-\s*name:\s*["']?(\w+)["']?/) - if (arrayMatch) { + if (arrayMatch && (parameterLevelIndent === -1 || currentIndent === parameterLevelIndent)) { console.log('[intellisense] Found array-format param:', arrayMatch[1]) definedParams.add(arrayMatch[1]) continue } - // Object format (multiline): " paramName:" on its own line - const objectMultilineMatch = line.match(/^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s*$/) - if (objectMultilineMatch) { - console.log('[intellisense] Found object-format param:', objectMultilineMatch[1]) - definedParams.add(objectMultilineMatch[1]) - continue - } - - // Inline object format: " paramName: { type: string }" - const inlineObjectMatch = line.match(/^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s*\{/) - if (inlineObjectMatch) { - console.log('[intellisense] Found inline-object param:', inlineObjectMatch[1]) - definedParams.add(inlineObjectMatch[1]) - continue + // Object format — only match at parameter level indent + if (parameterLevelIndent !== -1 && currentIndent === parameterLevelIndent) { + // Object format (multiline): " paramName:" on its own line + const objectMultilineMatch = line.match(/^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s*$/) + if (objectMultilineMatch) { + console.log('[intellisense] Found object-format param:', objectMultilineMatch[1]) + definedParams.add(objectMultilineMatch[1]) + continue + } + + // Inline object format: " paramName: { type: string }" + const inlineObjectMatch = line.match(/^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s*\{/) + if (inlineObjectMatch) { + console.log('[intellisense] Found inline-object param:', inlineObjectMatch[1]) + definedParams.add(inlineObjectMatch[1]) + continue + } } } } @@ -1273,16 +1521,19 @@ export async function validateModel( console.log('[intellisense] Defined params:', Array.from(definedParams)) // Extract loop variables from {% for VAR in COLLECTION %} or {%- for VAR in COLLECTION %} blocks - // Also handles [COLLECTION] bracket syntax (e.g., {% for item in [items] %}) - const forLoopPattern = /\{%-?\s*for\s+(\w+)\s+in\s+\[?\s*(\w+)/g + // Also handles tuple unpacking: {% for key, value in dict %} and [COLLECTION] bracket syntax + const forLoopPattern = /\{%-?\s*for\s+([\w,\s]+?)\s+in\s+\[?\s*(\w+)/g console.log('[intellisense] Searching for loop patterns in content length:', content.length) const loopMatchArray = Array.from(content.matchAll(forLoopPattern)) console.log('[intellisense] Found', loopMatchArray.length, 'for loop matches') for (const match of loopMatchArray) { - const loopVar = match[1] + // Split on comma to handle tuple unpacking (e.g., "service, owner") + const vars = match[1].split(',').map(v => v.trim()).filter(Boolean) const collectionVar = match[2] - console.log('[intellisense] Found loop variable:', loopVar, 'iterating over:', collectionVar, 'at index:', match.index) - definedParams.add(loopVar) + for (const loopVar of vars) { + console.log('[intellisense] Found loop variable:', loopVar, 'iterating over:', collectionVar, 'at index:', match.index) + definedParams.add(loopVar) + } // Also add 'loop' helper variable (Nunjucks built-in) definedParams.add('loop') } @@ -1305,7 +1556,12 @@ export async function validateModel( definedParams.add('previous_step') // Alias for previous_output definedParams.add('input') // Alias for previous_output in code/transform nodes - console.log('[intellisense] After loop/set extraction, definedParams:', Array.from(definedParams)) + // Add inherited parameters so they're recognized as defined + for (const inheritedParam of resolvedInheritedParams) { + definedParams.add(inheritedParam.name) + } + + console.log('[intellisense] After loop/set/inherited extraction, definedParams:', Array.from(definedParams)) // Check body for parameter references (handle CRLF) const bodyMatch = content.match(/---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/) @@ -1313,6 +1569,25 @@ export async function validateModel( const body = bodyMatch[1] const bodyStartOffset = frontmatterMatch[0].length + // Build a set of line numbers that are inside fenced code blocks + // so we can skip single-brace references inside code blocks (e.g., JSON keys) + const codeBlockLines = new Set() + const bodyLines = body.split(/\r?\n/) + let inCodeBlock = false + for (let i = 0; i < bodyLines.length; i++) { + if (/^\s*```/.test(bodyLines[i])) { + if (inCodeBlock) { + codeBlockLines.add(i) // closing fence + inCodeBlock = false + } else { + codeBlockLines.add(i) // opening fence + inCodeBlock = true + } + } else if (inCodeBlock) { + codeBlockLines.add(i) + } + } + // Find single-brace references {var} that are NOT part of double braces {{var}} // Use negative lookbehind/lookahead to exclude {{ and }} const singleBraceRegex = /(? 0 ? resolvedInheritedParams : undefined + const crossRefDiagnostics = analyzeParameterUsage(content, monaco, inheritedDefs) markers.push(...crossRefDiagnostics) } catch (error) { console.warn('[intellisense] Error during cross-reference analysis:', error) diff --git a/frontend/src/modules/services/LocalLLMClient.ts b/frontend/src/modules/services/LocalLLMClient.ts index e6b2a09..d939be9 100644 --- a/frontend/src/modules/services/LocalLLMClient.ts +++ b/frontend/src/modules/services/LocalLLMClient.ts @@ -61,17 +61,30 @@ export class LocalLLMClient implements IPrompdLLMClient { const modelData = providerData?.models.find(m => m.model === model) const enableImageGeneration = modelData?.supportsImageGeneration === true - // Execute locally - const result = await localExecutor.execute({ + // Only pass thinking mode if the model supports it + const supportsThinking = modelData?.supportsThinking === true + const effectiveMode = (request.mode === 'thinking' && !supportsThinking) + ? 'default' + : request.mode as 'default' | 'thinking' | 'json' | undefined + + const execOptions = { provider, model, prompt, systemPrompt, temperature: request.temperature, maxTokens: request.maxTokens, - stream: false, - enableImageGeneration - }) + enableImageGeneration, + mode: effectiveMode + } + + // If onChunk callback is provided, use streaming execution + if (request.onChunk) { + return await this.sendStreaming(request.onChunk, execOptions, provider, model) + } + + // Non-streaming path + const result = await localExecutor.execute({ ...execOptions, stream: false }) if (!result.success) { throw new Error(result.error || 'Execution failed') @@ -79,12 +92,14 @@ export class LocalLLMClient implements IPrompdLLMClient { return { content: result.response || '', + thinking: result.thinking, provider, model, usage: result.usage, metadata: { executionMode: 'local', - duration: result.metadata.duration + duration: result.metadata.duration, + ...(result.thinking ? { thinking: result.thinking } : {}) } } } catch (error) { @@ -97,6 +112,55 @@ export class LocalLLMClient implements IPrompdLLMClient { } } + /** + * Streaming execution — calls localExecutor.stream() and delivers chunks via onChunk + */ + private async sendStreaming( + onChunk: (chunk: { content?: string; thinking?: string; done: boolean }) => void, + execOptions: Record, + provider: string, + model: string + ): Promise { + let fullContent = '' + let fullThinking = '' + let finalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + const startTime = Date.now() + + const generator = localExecutor.stream({ ...execOptions, stream: true } as Parameters[0]) + + for await (const chunk of generator) { + // Access thinking field (exists at runtime on Anthropic chunks but not in StreamChunk type) + const chunkAny = chunk as unknown as { content: string; thinking?: string; done: boolean; usage?: typeof finalUsage } + if (chunkAny.content) fullContent += chunkAny.content + if (chunkAny.thinking) fullThinking += chunkAny.thinking + if (chunkAny.usage) finalUsage = chunkAny.usage + + const thinkingDelta = chunkAny.thinking + onChunk({ + content: chunk.content || undefined, + thinking: thinkingDelta || undefined, + done: chunk.done + }) + } + + onChunk({ done: true }) + + const duration = Date.now() - startTime + + return { + content: fullContent, + thinking: fullThinking || undefined, + provider, + model, + usage: finalUsage, + metadata: { + executionMode: 'local', + duration, + ...(fullThinking ? { thinking: fullThinking } : {}) + } + } + } + /** * Convert OpenAI-style messages to single prompt string */ diff --git a/frontend/src/modules/services/chatModesApi.ts b/frontend/src/modules/services/chatModesApi.ts index b301b9c..7b652bc 100644 --- a/frontend/src/modules/services/chatModesApi.ts +++ b/frontend/src/modules/services/chatModesApi.ts @@ -11,6 +11,16 @@ export interface ChatModeConfig { icon: string description: string systemPrompt: string + settings?: { + maxIterations?: number + streamResponses?: boolean + permissionLevels?: Record + defaultPermissionLevel?: string + } followUpStrategies?: { detailed?: string vague?: string @@ -60,42 +70,60 @@ function cacheModes(modes: ChatModesResponse): void { } } +// Module-level singleton: one fetch shared across all callers +let inflight: Promise | null = null +let resolved: ChatModesResponse | null = null + /** - * Fetch all chat mode configurations from the backend - * Falls back to cache if offline or API fails + * Fetch all chat mode configurations from the backend. + * Deduplicates concurrent calls — first caller triggers the fetch, + * all subsequent callers share the same promise/result. + * Falls back to localStorage cache if offline or API fails. */ export async function fetchChatModes(): Promise { - const base = getApiBaseUrl() - const url = `${base}/chat-modes` - console.log('[chatModesApi] API base:', base) - console.log('[chatModesApi] Full URL:', url) + // Return cached result immediately if already fetched this session + if (resolved) return resolved - try { - const response = await fetch(url) + // Return in-flight promise if a fetch is already in progress + if (inflight) return inflight - if (!response.ok) { - throw new Error(`Failed to fetch chat modes: ${response.statusText} (URL: ${url}, base: ${base})`) - } + inflight = (async () => { + const base = getApiBaseUrl() + const url = `${base}/chat-modes` - const data = await response.json() + try { + const response = await fetch(url) - // Cache the fresh data - cacheModes(data) + if (!response.ok) { + throw new Error(`Failed to fetch chat modes: ${response.statusText} (URL: ${url}, base: ${base})`) + } - return data - } catch (error) { - console.warn('[chatModesApi] API fetch failed, trying cache:', error) + const data = await response.json() - // Try to use cached data - const cached = getCachedModes() - if (cached) { - console.log('[chatModesApi] Using cached chat modes (offline mode)') - return cached + // Cache the fresh data + cacheModes(data) + resolved = data + + return data + } catch (error) { + console.warn('[chatModesApi] API fetch failed, trying cache:', error) + + // Try to use cached data + const cached = getCachedModes() + if (cached) { + console.log('[chatModesApi] Using cached chat modes (offline mode)') + resolved = cached + return cached + } + + // No cache available, throw error + throw new Error(`Failed to fetch chat modes and no cache available: ${error}`) + } finally { + inflight = null } + })() - // No cache available, throw error - throw new Error(`Failed to fetch chat modes and no cache available: ${error}`) - } + return inflight } /** diff --git a/frontend/src/modules/services/contextWindowResolver.ts b/frontend/src/modules/services/contextWindowResolver.ts index 1ec99fa..09afe8c 100644 --- a/frontend/src/modules/services/contextWindowResolver.ts +++ b/frontend/src/modules/services/contextWindowResolver.ts @@ -4,6 +4,11 @@ * Resolves the context window size for a given provider/model combination. * Checks dynamic provider data (from API/uiStore) first, falls back to * KNOWN_PROVIDERS static data, then to a conservative default. + * + * Also provides `resolveEffectiveContextWindow` which caps the context + * window at a practical limit for compaction and UI display. Even models + * with 1M+ token windows see degraded response quality at extreme lengths, + * and showing "1% of 1M" is not useful to users. */ import { KNOWN_PROVIDERS } from './providers/types' @@ -13,7 +18,16 @@ import type { ProviderWithPricing } from '../../stores/uiStore' const DEFAULT_CONTEXT_WINDOW = 128000 /** - * Resolve context window size for the current provider/model. + * Maximum effective context window for compaction and UI display. + * Models with 1M+ token windows technically support huge contexts, but + * response quality degrades and compaction would never trigger. + * Cap at 128K to keep compaction and the % display meaningful. + */ +const MAX_EFFECTIVE_CONTEXT_WINDOW = 128000 + +/** + * Resolve the raw context window size for the current provider/model. + * Returns the actual model limit without any capping. * * @param provider - Provider ID (e.g., 'anthropic', 'openai') * @param model - Model ID (e.g., 'claude-3-5-sonnet-20241022') @@ -49,6 +63,24 @@ export function resolveContextWindowSize( return DEFAULT_CONTEXT_WINDOW } +/** + * Resolve an effective context window capped at a practical limit. + * Used for compaction thresholds and UI percentage display. + * + * Models like GPT-4.1 Nano (1M tokens) or Gemini (2M tokens) have massive + * windows that make percentage-based compaction useless — the 75% threshold + * would require 750K+ tokens. This function caps at 128K so compaction + * triggers at a reasonable conversation length. + */ +export function resolveEffectiveContextWindow( + provider: string, + model: string, + configuredProviders?: ProviderWithPricing[] | null +): number { + const raw = resolveContextWindowSize(provider, model, configuredProviders) + return Math.min(raw, MAX_EFFECTIVE_CONTEXT_WINDOW) +} + /** * Format a context window size for display. * @example formatContextWindow(200000) => "200K" diff --git a/frontend/src/modules/services/electronFetch.ts b/frontend/src/modules/services/electronFetch.ts index f111177..a695b11 100644 --- a/frontend/src/modules/services/electronFetch.ts +++ b/frontend/src/modules/services/electronFetch.ts @@ -68,3 +68,126 @@ export async function electronFetch(url: string, options?: ElectronFetchOptions) // Fall back to regular fetch (browser or web mode) return fetch(url, options) } + +/** + * Streaming fetch that uses IPC events for incremental data delivery. + * Returns a Response with a real ReadableStream body backed by IPC events, + * so getReader().read() yields chunks as they arrive from the HTTP response. + * Use this for SSE/streaming endpoints (LLM APIs). + * Falls back to regular fetch in non-Electron environments. + */ +export async function electronStreamFetch(url: string, options?: ElectronFetchOptions): Promise { + interface StreamElectronAPI { + apiStreamRequest: (url: string, options: Record, streamId: string) => Promise + onApiStreamChunk: (callback: (streamId: string, data: string) => void) => () => void + onApiStreamEnd: (callback: (streamId: string) => void) => () => void + onApiStreamError: (callback: (streamId: string, error: string) => void) => () => void + } + + const electronAPI = (window as unknown as { electronAPI?: StreamElectronAPI })?.electronAPI + + if (!electronAPI?.apiStreamRequest) { + // Fall back to regular fetch (browser/web mode) + return fetch(url, options) + } + + const streamId = Math.random().toString(36).slice(2) + Date.now().toString(36) + + // Buffer for chunks that arrive before ReadableStream controller is ready + const pendingChunks: string[] = [] + let streamEnded = false + let streamError: string | null = null + let controller: ReadableStreamDefaultController | null = null + const encoder = new TextEncoder() + + const cleanupFns: Array<() => void> = [] + const cleanupAll = () => { + for (const fn of cleanupFns) fn() + cleanupFns.length = 0 + } + + // Set up listeners BEFORE starting the request to avoid missing chunks + cleanupFns.push(electronAPI.onApiStreamChunk((id: string, data: string) => { + if (id !== streamId) return + if (controller) { + controller.enqueue(encoder.encode(data)) + } else { + pendingChunks.push(data) + } + })) + + cleanupFns.push(electronAPI.onApiStreamEnd((id: string) => { + if (id !== streamId) return + streamEnded = true + if (controller) { + controller.close() + cleanupAll() + } + })) + + cleanupFns.push(electronAPI.onApiStreamError((id: string, error: string) => { + if (id !== streamId) return + streamError = error + if (controller) { + controller.error(new Error(error)) + cleanupAll() + } + })) + + // Convert headers (same logic as electronFetch) + let headerObj: Record = {} + if (options?.headers) { + if (options.headers instanceof Headers) { + options.headers.forEach((value, key) => { headerObj[key] = value }) + } else if (Array.isArray(options.headers)) { + options.headers.forEach(([key, value]) => { headerObj[key] = value }) + } else { + headerObj = Object.fromEntries( + Object.entries(options.headers).map(([k, v]) => [k, String(v)]) + ) + } + } + + // Start the request - resolves when response headers arrive + const result = await electronAPI.apiStreamRequest(url, { + method: (options?.method || 'GET'), + headers: headerObj, + body: options?.body ? (typeof options.body === 'string' ? JSON.parse(options.body) : options.body) : undefined, + }, streamId) + + if (!result.success) { + cleanupAll() + throw new Error(result.error || 'Stream request failed') + } + + // Create ReadableStream backed by IPC events + const readableStream = new ReadableStream({ + start(ctrl) { + controller = ctrl + + // Flush any chunks that arrived while we were setting up + for (const chunk of pendingChunks) { + ctrl.enqueue(encoder.encode(chunk)) + } + pendingChunks.length = 0 + + // Check if stream already completed while we were setting up + if (streamError) { + ctrl.error(new Error(streamError)) + cleanupAll() + } else if (streamEnded) { + ctrl.close() + cleanupAll() + } + }, + cancel() { + cleanupAll() + } + }) + + return new Response(readableStream, { + status: result.status, + statusText: result.statusText, + headers: new Headers(result.headers || {}) + }) +} diff --git a/frontend/src/modules/services/executionRouter.ts b/frontend/src/modules/services/executionRouter.ts index 7e9121c..6eafaf9 100644 --- a/frontend/src/modules/services/executionRouter.ts +++ b/frontend/src/modules/services/executionRouter.ts @@ -54,6 +54,8 @@ export interface ExecutionOptions { export interface ExecutionRouterResult { success: boolean response?: string + /** Thinking content from models with extended thinking (e.g., Claude) */ + thinking?: string error?: string compiledPrompt?: string usage: { @@ -196,10 +198,14 @@ class ExecutionRouterService { } let fullResponse = '' + let fullThinking = '' let finalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } for await (const chunk of localExecutor.stream(localOptions)) { fullResponse += chunk.content + if (chunk.thinking) { + fullThinking += chunk.thinking + } if (chunk.usage) { finalUsage = chunk.usage } @@ -218,7 +224,8 @@ class ExecutionRouterService { model: options.model, duration, executionMode: 'local', - compiledLocally + compiledLocally, + ...(fullThinking ? { thinking: fullThinking } : {}) } } } else { @@ -278,6 +285,7 @@ class ExecutionRouterService { return { success: result.success, response: result.response, + thinking: result.thinking, error: result.error, compiledPrompt, usage: result.usage, diff --git a/frontend/src/modules/services/executionService.ts b/frontend/src/modules/services/executionService.ts index 37147c8..57b4e35 100644 --- a/frontend/src/modules/services/executionService.ts +++ b/frontend/src/modules/services/executionService.ts @@ -120,7 +120,7 @@ function calculateEstimatedCost( * Check if the selected model supports image generation * Uses pricing data from uiStore (populated from backend API) */ -function modelSupportsImageGeneration(provider: string, model: string): boolean { +export function modelSupportsImageGeneration(provider: string, model: string): boolean { const providersWithPricing = useUIStore.getState().llmProvider.providersWithPricing if (!providersWithPricing) return false const providerData = providersWithPricing.find(p => p.providerId === provider) @@ -480,7 +480,7 @@ export async function executePrompdConfig( maxTokens: config.maxTokens ?? 4096, temperature: config.temperature ?? 0.7, mode: config.mode ?? 'default', - enableImageGeneration: modelSupportsImageGeneration(config.provider, config.model) + enableImageGeneration: (config.imageGeneration !== false) && modelSupportsImageGeneration(config.provider, config.model) }) console.log('[executionService] Router result:', { diff --git a/frontend/src/modules/services/fileContextBuilder.ts b/frontend/src/modules/services/fileContextBuilder.ts index c18c07e..113419c 100644 --- a/frontend/src/modules/services/fileContextBuilder.ts +++ b/frontend/src/modules/services/fileContextBuilder.ts @@ -7,10 +7,17 @@ import { parsePrompd } from '../lib/prompdParser' +export interface ValidationIssue { + message: string + line?: number + severity?: string +} + export interface FileContext { fileName: string | null content: string cursorPosition?: { line: number; column: number } + errors?: ValidationIssue[] } export interface ContextMessage { @@ -24,7 +31,7 @@ export interface ContextMessage { * syntax hints for other file types. */ export function buildFileContextMessages(context: FileContext): ContextMessage[] { - const { fileName, content, cursorPosition } = context + const { fileName, content, cursorPosition, errors } = context if (!content || typeof content !== 'string') { return [] @@ -129,10 +136,21 @@ When modifying this file: ? `\n\n**IMPORTANT**: When using edit_file or write_file for this file, use path: \`${filePath}\`` : '' + // Build validation issues section if errors are present + let validationSection = '' + if (errors && errors.length > 0) { + const issueLines = errors.map(e => { + const loc = e.line ? `Line ${e.line}` : 'Unknown' + const sev = e.severity ? `[${e.severity}]` : '[ERROR]' + return `- ${loc}: ${sev} ${e.message}` + }) + validationSection = `\n\n### Validation Issues\n${issueLines.join('\n')}` + } + // Build final context message const contextContent = fileTypeInstructions - ? `## Context File: ${filePath}${pathInstructions}${cursorInfo}\n\n${fileTypeInstructions}${metadataContext}\n\n\`\`\`${syntaxHint}\n${numberedContent}\n\`\`\`` - : `## Current File: ${filePath}${pathInstructions}${cursorInfo}${metadataContext}\n\n\`\`\`${syntaxHint}\n${numberedContent}\n\`\`\`` + ? `## Context File: ${filePath}${pathInstructions}${cursorInfo}\n\n${fileTypeInstructions}${metadataContext}${validationSection}\n\n\`\`\`${syntaxHint}\n${numberedContent}\n\`\`\`` + : `## Current File: ${filePath}${pathInstructions}${cursorInfo}${metadataContext}${validationSection}\n\n\`\`\`${syntaxHint}\n${numberedContent}\n\`\`\`` messages.push({ role: 'system' as const, diff --git a/frontend/src/modules/services/localExecutor.ts b/frontend/src/modules/services/localExecutor.ts index 1a6616f..8290255 100644 --- a/frontend/src/modules/services/localExecutor.ts +++ b/frontend/src/modules/services/localExecutor.ts @@ -58,6 +58,8 @@ export interface LocalExecuteOptions { export interface LocalExecuteResult { success: boolean response?: string + /** Thinking content from models with extended thinking */ + thinking?: string error?: string usage: { promptTokens: number @@ -187,6 +189,7 @@ class LocalExecutorService { return { success: result.success, response, + thinking: result.thinking, error: result.error, usage: result.usage, metadata: { diff --git a/frontend/src/modules/services/nodeTemplateService.ts b/frontend/src/modules/services/nodeTemplateService.ts index 4ab25e9..c3f5800 100644 --- a/frontend/src/modules/services/nodeTemplateService.ts +++ b/frontend/src/modules/services/nodeTemplateService.ts @@ -179,21 +179,25 @@ export function extractTemplateData( const uniqueFiles = [...new Set(allFiles)] const uniquePackages = [...new Set(allPackages)] + const nodeData_ = { + nodeType: node.type, + nodeData, + originalId: node.id, + dimensions, + children: children && children.length > 0 ? children : undefined, + edges: edges && edges.length > 0 ? edges : undefined, + } + return { version: '1.0', type: 'node-template', name: (node.data as { label?: string }).label || registry?.label || node.type, - nodeTypeLabel: registry?.label || node.type, createdAt: new Date().toISOString(), files: uniqueFiles.length > 0 ? uniqueFiles : undefined, packages: uniquePackages.length > 0 ? uniquePackages : undefined, - node: { - nodeType: node.type, - nodeData, - originalId: node.id, - dimensions, - children: children && children.length > 0 ? children : undefined, - edges: edges && edges.length > 0 ? edges : undefined, + 'node-template': { + nodeTypeLabel: registry?.label || node.type, + node: nodeData_, }, } } diff --git a/frontend/src/modules/services/nodeTemplateTypes.ts b/frontend/src/modules/services/nodeTemplateTypes.ts index 23ceb7e..5bbf9c8 100644 --- a/frontend/src/modules/services/nodeTemplateTypes.ts +++ b/frontend/src/modules/services/nodeTemplateTypes.ts @@ -38,13 +38,17 @@ export interface NodeTemplateNodeData { export interface NodeTemplate { version: '1.0' type: 'node-template' - name: string + id?: string // Slugified package identifier (e.g., "chat-agent-with-tools") + name: string // Human-readable display name (e.g., "Chat Agent With Tools") description?: string - nodeTypeLabel: string createdAt: string files?: string[] // workspace-relative file paths bundled in the .pdpkg packages?: string[] // package references (@ns/package@version) that need to be installed - node: NodeTemplateNodeData + 'node-template': { + nodeTypeLabel: string + node: NodeTemplateNodeData + pathsConverted?: boolean + } } /** List item returned by template:list IPC */ @@ -55,6 +59,7 @@ export interface TemplateListItem { nodeType: WorkflowNodeType nodeTypeLabel: string scope: 'workspace' | 'user' + origin?: 'local' | 'registry' createdAt: string } diff --git a/frontend/src/modules/services/nodeTypeRegistry.ts b/frontend/src/modules/services/nodeTypeRegistry.ts index 14c4a8a..c8f11a2 100644 --- a/frontend/src/modules/services/nodeTypeRegistry.ts +++ b/frontend/src/modules/services/nodeTypeRegistry.ts @@ -25,6 +25,7 @@ import { ShieldCheck, Wrench, Terminal, Search, FileCode, Globe, Plug, ScanSearch, Route, GitBranch, Repeat, GitFork, Combine, Wand2, Database, TableProperties, UserCircle, Eye, AlertTriangle, Workflow, + Group, Sparkles, } from 'lucide-react' import type { WorkflowNodeType } from './workflowTypes' @@ -45,6 +46,7 @@ export interface NodeTypeCategory { key: string label: string paletteLabel: string // May differ from context menu label + description: string // Brief help text shown on hover in palette types: WorkflowNodeType[] } @@ -118,6 +120,10 @@ export const NODE_TYPE_REGISTRY: Record = { type: 'database-query', label: 'DB Query', description: 'Query a database connection', icon: TableProperties, color: 'var(--node-teal)', colorVar: 'teal', }, + 'skill': { + type: 'skill', label: 'Skill', description: 'Execute an installed skill package', + icon: Sparkles, color: 'var(--node-violet)', colorVar: 'violet', + }, // --- Add new tool/execution node types here --- // Tool Routing @@ -181,6 +187,10 @@ export const NODE_TYPE_REGISTRY: Record = { type: 'workflow', label: 'Sub-Workflow', description: 'Invoke another .pdflow', icon: Workflow, color: 'var(--node-green)', colorVar: 'teal', }, + 'node-group': { + type: 'node-group', label: 'Group', description: 'Visual grouping for template export', + icon: Group, color: 'var(--node-slate)', colorVar: 'slate', + }, } // ============================================================================ @@ -192,49 +202,57 @@ export const NODE_TYPE_CATEGORIES: NodeTypeCategory[] = [ key: 'entry-exit', label: 'Core', paletteLabel: 'Entry & Exit', + description: 'Start or end your workflow. Every workflow needs a Trigger to begin.', types: ['trigger', 'output'], }, { key: 'ai-prompts', label: 'AI & Agents', paletteLabel: 'AI & Prompts', + description: 'AI agents, prompts, and validation guardrails.', types: ['prompt', 'provider', 'agent', 'chat-agent', 'claude-code', 'guardrail'], }, { key: 'tools-execution', label: 'Tools & Execution', paletteLabel: 'Tools & Execution', - types: ['tool', 'command', 'web-search', 'code', 'api', 'mcp-tool', 'database-query'], + description: 'Execute commands, code, APIs, and external tools.', + types: ['tool', 'command', 'web-search', 'code', 'api', 'mcp-tool', 'database-query', 'skill'], }, { key: 'tool-routing', label: 'Tool Routing', paletteLabel: 'Tool Routing', + description: 'Route and parse tool calls between AI agents and tools.', types: ['tool-call-parser', 'tool-call-router'], }, { key: 'control-flow', label: 'Control Flow', paletteLabel: 'Control Flow', + description: 'Conditions, loops, and parallel execution.', types: ['condition', 'loop', 'parallel', 'merge'], }, { key: 'data', label: 'Data & Transform', paletteLabel: 'Data', + description: 'Transform data with templates and store state in memory.', types: ['transformer', 'memory'], }, { key: 'interaction', label: 'Interaction', paletteLabel: 'Interaction & Debug', + description: 'Pause for user input, log checkpoints, or handle errors.', types: ['user-input', 'callback', 'error-handler'], }, { key: 'composition', label: 'Composition', paletteLabel: 'Composition', - types: ['workflow'], + description: 'Build complex workflows from sub-workflows and grouped nodes.', + types: ['workflow', 'node-group'], }, ] diff --git a/frontend/src/modules/services/packageService.ts b/frontend/src/modules/services/packageService.ts index 7b817b8..ad65aba 100644 --- a/frontend/src/modules/services/packageService.ts +++ b/frontend/src/modules/services/packageService.ts @@ -1,11 +1,34 @@ import { prompdSettings } from './prompdSettings' import { getApiBaseUrl } from './apiConfig' import { electronFetch } from './electronFetch' +import type { ResourceType } from './resourceTypes' +import type { NodeTemplateNodeData } from './nodeTemplateTypes' + +/** Type-specific section for node-template packages */ +export interface NodeTemplateSectionData { + nodeTypeLabel: string + node: NodeTemplateNodeData + pathsConverted?: boolean +} + +/** Type-specific section for skill packages */ +export interface SkillSectionData { + allowedTools?: string[] // Tools the skill is permitted to use at runtime + parameters?: Record // JSON Schema for input parameters +} + +/** Type-specific section for workflow packages */ +export interface WorkflowSectionData { + entrypoint?: string + triggers?: Record[] + parameters?: Record +} export interface PackageManifest { name: string version: string description: string + type?: ResourceType author?: string license?: string keywords?: string[] @@ -14,6 +37,12 @@ export interface PackageManifest { repository?: string files?: string[] ignore?: string[] // Glob patterns for files to exclude from package + tools?: string[] // Required tool names (universal dependency declaration) + mcps?: string[] // MCP server names required by this package + // Type-specific sections (keyed by the resource type) + 'node-template'?: NodeTemplateSectionData + 'skill'?: SkillSectionData + 'workflow'?: WorkflowSectionData } // Check if running in Electron with local package support @@ -34,6 +63,12 @@ export interface Namespace { frozen?: boolean } +export interface CreatePackageResult { + blob: Blob + /** On-disk path to the .pdpkg file (only available in Electron/local mode) */ + outputPath?: string +} + export class PackageService { /** * Create package using local CLI (Electron) or backend API (web) @@ -44,7 +79,7 @@ export class PackageService { manifest: PackageManifest, getToken: () => Promise, options: CreatePackageOptions = {} - ): Promise { + ): Promise { console.log('[PackageService] Creating package:', manifest.name) const selectedFiles = manifest.files || [] if (selectedFiles.length === 0) { @@ -159,18 +194,19 @@ export class PackageService { throw new Error(error.error || 'Package creation failed') } - // Return blob + // Return blob (no outputPath in web mode) const blob = await response.blob() console.log(`[PackageService] ✅ Package created: ${blob.size} bytes`) - return blob + return { blob } } /** - * Publish package to registry via backend + * Publish package to registry directly via Electron IPC + * Sends the .pdpkg file directly to the registry (no backend proxy). */ async publish( - packageBlob: Blob, + filePath: string | undefined, manifest: PackageManifest, getToken: () => Promise, onProgress?: (percent: number) => void, @@ -178,69 +214,50 @@ export class PackageService { ): Promise { console.log('[PackageService] Publishing:', manifest.name) - const formData = new FormData() - formData.append('manifest', JSON.stringify(manifest)) - formData.append('package', packageBlob, `${manifest.name.replace('/', '-')}-${manifest.version}.pdpkg`) - - // Pass registry-specific auth to backend so it uses the right token and URL - if (registryConfig?.apiKey) { - formData.append('registryApiKey', registryConfig.apiKey) + if (!filePath) { + throw new Error('No package file path available. Package creation may have failed.') } - if (registryConfig?.url) { - formData.append('registryUrl', registryConfig.url) + + const electronAPI = (window as any).electronAPI + if (!electronAPI?.package?.publish) { + throw new Error('Electron IPC not available for publishing.') } - const token = await getToken() - if (!token) { - throw new Error('Authentication required. Please sign in to publish packages.') + // Use registry API key if available, otherwise fall back to auth token + let authToken: string | null = registryConfig?.apiKey || null + if (!authToken) { + authToken = await getToken() + } + if (!authToken) { + throw new Error('Authentication required. Please sign in or configure a registry API key.') } - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() + // Resolve registry URL + const registryUrl = registryConfig?.url || prompdSettings.getRegistryUrl() - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable && onProgress) { - onProgress((e.loaded / e.total) * 100) - } - }) - - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - const result = JSON.parse(xhr.responseText) - console.log('[PackageService] Published:', result.data) - resolve() - } catch { - resolve() - } - } else { - try { - const error = JSON.parse(xhr.responseText) - // Extract the actual error message - details may be JSON stringified - let errorMessage = error.error || 'Publish failed' - if (error.details) { - try { - const details = typeof error.details === 'string' ? JSON.parse(error.details) : error.details - if (details.error) { - errorMessage = details.error - } - } catch { - // If details isn't valid JSON, use it as-is - errorMessage = error.details - } - } - reject(new Error(errorMessage)) - } catch { - reject(new Error(`Publish failed: ${xhr.status} ${xhr.statusText}`)) - } - } - }) + if (onProgress) onProgress(10) - xhr.addEventListener('error', () => reject(new Error('Network error'))) - xhr.open('POST', `${getApiBaseUrl()}/packages/publish`) - xhr.setRequestHeader('Authorization', `Bearer ${token}`) - xhr.send(formData) + // Pass manifest fields as metadata overrides so the IPC handler + // can merge them into the archive's prompd.json metadata (e.g., scoped name) + const metadataOverrides: Record = {} + if (manifest.name) metadataOverrides.name = manifest.name + if (manifest.version) metadataOverrides.version = manifest.version + if (manifest.description) metadataOverrides.description = manifest.description + + const result = await electronAPI.package.publish({ + filePath, + registryUrl, + authToken, + metadataOverrides: Object.keys(metadataOverrides).length > 0 ? metadataOverrides : undefined, }) + + if (onProgress) onProgress(100) + + if (!result.success) { + throw new Error(result.error || 'Publish failed') + } + + console.log('[PackageService] Published via IPC:', result.data) } /** @@ -300,15 +317,17 @@ export class PackageService { private async createPackageLocal( workspacePath: string, manifest: PackageManifest - ): Promise { + ): Promise { console.log('[PackageService] Using local CLI for package creation') const electronAPI = (window as any).electronAPI // First, ensure prompd.json is up to date with the manifest - const manifestContent = JSON.stringify({ + // Build a clean object — omit undefined values so they don't pollute the JSON + const manifestObj: Record = { name: manifest.name, version: manifest.version, description: manifest.description, + type: manifest.type, author: manifest.author, license: manifest.license, keywords: manifest.keywords, @@ -316,8 +335,16 @@ export class PackageService { repository: manifest.repository, main: manifest.main, files: manifest.files, - ignore: manifest.ignore - }, null, 2) + ignore: manifest.ignore, + tools: manifest.tools, + mcps: manifest.mcps, + } + // Include type-specific sections when present + if (manifest['node-template']) manifestObj['node-template'] = manifest['node-template'] + if (manifest.skill) manifestObj.skill = manifest.skill + if (manifest.workflow) manifestObj.workflow = manifest.workflow + + const manifestContent = JSON.stringify(manifestObj, null, 2) const manifestPath = `${workspacePath}/prompd.json`.replace(/\\/g, '/') const writeResult = await electronAPI.writeFile(manifestPath, manifestContent) @@ -333,10 +360,11 @@ export class PackageService { throw new Error(result.error || 'Local package creation failed') } - console.log('[PackageService] Local CLI created package:', result.outputPath) + const outputPath: string = result.outputPath + console.log('[PackageService] Local CLI created package:', outputPath) // Read the created .pdpkg file as a blob (readBinaryFile returns base64) - const readResult = await electronAPI.readBinaryFile(result.outputPath) + const readResult = await electronAPI.readBinaryFile(outputPath) if (!readResult.success) { throw new Error(`Failed to read package file: ${readResult.error}`) } @@ -350,7 +378,7 @@ export class PackageService { const blob = new Blob([bytes], { type: 'application/zip' }) console.log(`[PackageService] Package blob created: ${blob.size} bytes`) - return blob + return { blob, outputPath } } /** diff --git a/frontend/src/modules/services/providers/base.ts b/frontend/src/modules/services/providers/base.ts index 2996663..db570f5 100644 --- a/frontend/src/modules/services/providers/base.ts +++ b/frontend/src/modules/services/providers/base.ts @@ -14,7 +14,7 @@ import type { TokenUsage, ProviderEntry } from './types' -import { electronFetch } from '../electronFetch' +import { electronFetch, electronStreamFetch } from '../electronFetch' /** * Abstract base class for LLM providers @@ -65,14 +65,19 @@ export abstract class BaseProvider implements IExecutionProvider { protected createSuccessResult( response: string, usage: TokenUsage, - duration: number + duration: number, + thinking?: string ): ExecutionResult { - return { + const result: ExecutionResult = { success: true, response, usage, duration } + if (thinking) { + result.thinking = thinking + } + return result } } @@ -94,6 +99,26 @@ export class OpenAICompatibleProvider extends BaseProvider { this.baseUrl = baseUrlOverride || config.baseUrl } + /** + * OpenAI reasoning models (o1, o3, etc.) use `max_completion_tokens` + * instead of `max_tokens`. Detect by model name pattern. + */ + private isReasoningModel(model: string): boolean { + return /^o\d/.test(model) + } + + /** + * Set the correct token limit parameter based on model type. + * Reasoning models use max_completion_tokens; all others use max_tokens. + */ + private setMaxTokens(body: Record, model: string, maxTokens: number): void { + if (this.name === 'openai' && this.isReasoningModel(model)) { + body.max_completion_tokens = maxTokens + } else { + body.max_tokens = maxTokens + } + } + async execute(request: ExecutionRequest): Promise { const startTime = Date.now() @@ -122,12 +147,17 @@ export class OpenAICompatibleProvider extends BaseProvider { } if (request.maxTokens) { - body.max_tokens = request.maxTokens + this.setMaxTokens(body, request.model, request.maxTokens) } if (request.temperature !== undefined) { body.temperature = request.temperature } + // Reasoning models don't support temperature + if (this.name === 'openai' && this.isReasoningModel(request.model)) { + delete body.temperature + } + // JSON mode - request structured JSON output if (request.mode === 'json') { body.response_format = { type: 'json_object' } @@ -201,18 +231,23 @@ export class OpenAICompatibleProvider extends BaseProvider { } if (request.maxTokens) { - body.max_tokens = request.maxTokens + this.setMaxTokens(body, request.model, request.maxTokens) } if (request.temperature !== undefined) { body.temperature = request.temperature } + // Reasoning models don't support temperature + if (this.name === 'openai' && this.isReasoningModel(request.model)) { + delete body.temperature + } + // JSON mode - request structured JSON output if (request.mode === 'json') { body.response_format = { type: 'json_object' } } - const response = await electronFetch(`${this.baseUrl}/chat/completions`, { + const response = await electronStreamFetch(`${this.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -367,10 +402,14 @@ export class AnthropicProvider extends BaseProvider { { role: 'user', content: request.prompt } ] - // For thinking mode, ensure minimum 1024 tokens for both max_tokens and budget + // For thinking mode, max_tokens must be strictly greater than budget_tokens. + // We set budget_tokens to the user's maxTokens and max_tokens to budget + 16k for the response. const isThinking = request.mode === 'thinking' - const maxTokens = isThinking + const budgetTokens = isThinking ? Math.max(1024, request.maxTokens || 4096) + : 0 + const maxTokens = isThinking + ? budgetTokens + 16384 : (request.maxTokens || 4096) const body: Record = { @@ -386,9 +425,9 @@ export class AnthropicProvider extends BaseProvider { body.temperature = request.temperature } - // Extended thinking mode - uses budget_tokens (minimum 1024) + // Extended thinking mode - budget_tokens must be < max_tokens if (isThinking) { - body.thinking = { type: 'enabled', budget_tokens: maxTokens } + body.thinking = { type: 'enabled', budget_tokens: budgetTokens } } const response = await electronFetch(`${this.baseUrl}/v1/messages`, { @@ -414,13 +453,13 @@ export class AnthropicProvider extends BaseProvider { // With thinking mode, there may be thinking blocks followed by text blocks // Image blocks (from multimodal responses) are converted to markdown syntax let content = '' + let thinking = '' if (data.content && Array.isArray(data.content)) { for (const block of data.content) { if (block.type === 'text') { content += block.text || '' } else if (block.type === 'thinking') { - // Include thinking content (summarized in Claude 4) - content += block.thinking || '' + thinking += block.thinking || '' } else if (block.type === 'image' && block.source?.data) { const mimeType = block.source.media_type || 'image/png' content += `\n\n![generated image](data:${mimeType};base64,${block.source.data})\n\n` @@ -433,7 +472,7 @@ export class AnthropicProvider extends BaseProvider { totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0) } - return this.createSuccessResult(content, usage, duration) + return this.createSuccessResult(content, usage, duration, thinking || undefined) } catch (error) { const duration = Date.now() - startTime const message = error instanceof Error ? error.message : 'Unknown error' @@ -446,10 +485,13 @@ export class AnthropicProvider extends BaseProvider { { role: 'user', content: request.prompt } ] - // For thinking mode, ensure minimum 1024 tokens for both max_tokens and budget + // For thinking mode, max_tokens must be strictly greater than budget_tokens. const isThinking = request.mode === 'thinking' - const maxTokens = isThinking + const budgetTokens = isThinking ? Math.max(1024, request.maxTokens || 4096) + : 0 + const maxTokens = isThinking + ? budgetTokens + 16384 : (request.maxTokens || 4096) const body: Record = { @@ -466,12 +508,12 @@ export class AnthropicProvider extends BaseProvider { body.temperature = request.temperature } - // Extended thinking mode - uses budget_tokens (minimum 1024) + // Extended thinking mode - budget_tokens must be < max_tokens if (isThinking) { - body.thinking = { type: 'enabled', budget_tokens: maxTokens } + body.thinking = { type: 'enabled', budget_tokens: budgetTokens } } - const response = await electronFetch(`${this.baseUrl}/v1/messages`, { + const response = await electronStreamFetch(`${this.baseUrl}/v1/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -495,6 +537,7 @@ export class AnthropicProvider extends BaseProvider { const decoder = new TextDecoder() let buffer = '' let totalUsage: TokenUsage | undefined + let currentBlockType = '' try { while (true) { @@ -519,12 +562,22 @@ export class AnthropicProvider extends BaseProvider { completionTokens: 0, totalTokens: json.message.usage.input_tokens || 0 } + } else if (json.type === 'content_block_start') { + currentBlockType = json.content_block?.type || 'text' } else if (json.type === 'content_block_delta') { - // Handle both regular text and thinking text deltas - const delta = json.delta?.text || json.delta?.thinking || '' - if (delta) { - yield { content: delta, done: false } + if (currentBlockType === 'thinking') { + const delta = json.delta?.thinking || '' + if (delta) { + yield { content: '', thinking: delta, done: false } + } + } else { + const delta = json.delta?.text || '' + if (delta) { + yield { content: delta, done: false } + } } + } else if (json.type === 'content_block_stop') { + currentBlockType = '' } else if (json.type === 'message_delta' && json.usage) { // Update with output tokens (comes at end) const outputTokens = json.usage.output_tokens || 0 @@ -671,7 +724,7 @@ export class GoogleGeminiProvider extends BaseProvider { const url = `${this.baseUrl}/v1beta/models/${request.model}:streamGenerateContent?alt=sse&key=${request.apiKey}` - const response = await electronFetch(url, { + const response = await electronStreamFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -823,7 +876,7 @@ export class CohereProvider extends BaseProvider { body.temperature = request.temperature } - const response = await electronFetch(`${this.baseUrl}/v1/chat`, { + const response = await electronStreamFetch(`${this.baseUrl}/v1/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/modules/services/registryApi.ts b/frontend/src/modules/services/registryApi.ts index efc37e3..76cdd76 100644 --- a/frontend/src/modules/services/registryApi.ts +++ b/frontend/src/modules/services/registryApi.ts @@ -489,7 +489,13 @@ class RegistryApiClient { exports: pkg.exports || {}, parameters: this.extractParameters(pkg), downloads: pkg.downloads, - stars: pkg.stars + stars: pkg.stars, + type: pkg.type, + license: pkg.license, + publishedAt: pkg.publishedAt, + updatedAt: pkg.updatedAt, + owner: pkg.owner, + namespace: pkg.namespace, })) return { @@ -640,7 +646,13 @@ class RegistryApiClient { downloads: pkg.downloads, stars: pkg.stars, files: pkg.files || [], - fileCount: pkg.fileCount + fileCount: pkg.fileCount, + type: pkg.type, + license: pkg.license, + publishedAt: pkg.publishedAt, + updatedAt: pkg.updatedAt, + owner: pkg.owner, + namespace: pkg.namespace, })) } diff --git a/frontend/src/modules/services/registryDiscovery.ts b/frontend/src/modules/services/registryDiscovery.ts index 8efda15..3b94047 100644 --- a/frontend/src/modules/services/registryDiscovery.ts +++ b/frontend/src/modules/services/registryDiscovery.ts @@ -144,7 +144,7 @@ class RegistryDiscoveryService { searchParams: { search: 'search query text', tags: 'comma-separated tags', - type: 'package type (prompt, workflow, etc.)', + type: 'package type (package, node-template, workflow, skill)', scope: 'package scope', author: 'package author', limit: 'results per page (default: 20, max: 100)', diff --git a/frontend/src/modules/services/resourceTypes.ts b/frontend/src/modules/services/resourceTypes.ts new file mode 100644 index 0000000..2e7e2fe --- /dev/null +++ b/frontend/src/modules/services/resourceTypes.ts @@ -0,0 +1,45 @@ +// Resource type system for Prompd packages +// Shared constants used by PublishModal, PrompdJsonDesignView, PrompdJsonEditor, install routing, +// PackagePanel, PackageDetailsModal, and InstalledResourcesPanel + +import type { LucideIcon } from 'lucide-react' +import { Package, Workflow, Puzzle, Sparkles } from 'lucide-react' + +export type ResourceType = 'package' | 'workflow' | 'node-template' | 'skill' + +export const RESOURCE_TYPES: ResourceType[] = ['package', 'workflow', 'node-template', 'skill'] + +export const RESOURCE_TYPE_LABELS: Record = { + 'package': 'Package', + 'workflow': 'Workflow', + 'node-template': 'Node Template', + 'skill': 'Skill', +} + +export const RESOURCE_TYPE_DIRS: Record = { + 'package': 'packages', + 'workflow': 'workflows', + 'node-template': 'templates', + 'skill': 'skills', +} + +export const RESOURCE_TYPE_DESCRIPTIONS: Record = { + 'package': 'Standard prompt package', + 'workflow': 'Deployable workflow package', + 'node-template': 'Reusable node configuration', + 'skill': 'AI agent skill with tool declarations', +} + +export const RESOURCE_TYPE_ICONS: Record = { + 'package': Package, + 'workflow': Workflow, + 'node-template': Puzzle, + 'skill': Sparkles, +} + +export const RESOURCE_TYPE_COLORS: Record = { + 'package': '#3b82f6', + 'workflow': '#10b981', + 'node-template': '#f59e0b', + 'skill': '#8b5cf6', +} diff --git a/frontend/src/modules/services/slashCommandsLocal.ts b/frontend/src/modules/services/slashCommandsLocal.ts index 84ba44d..bd836cc 100644 --- a/frontend/src/modules/services/slashCommandsLocal.ts +++ b/frontend/src/modules/services/slashCommandsLocal.ts @@ -341,6 +341,7 @@ async function executeInstall(args: string, workspacePath?: string): Promise ${newFileName}`) - store.updateTab(tab.id, { - name: newFileName, - filePath: fullNewPath - }) - } - } + const normalizedNew = fullNewPath.replace(/\\/g, '/') + const newFileName = normalizedNew.split('/').pop() || newPath + + // Dispatch rename event so App.tsx handler can update tab name, + // filePath, and pseudo-handle in one place (same pattern as codeActions.ts). + // The event must fire BEFORE any direct tab updates so the handler + // can still find the tab by its old name. + window.dispatchEvent(new CustomEvent('prompd-file-renamed', { + detail: { oldPath: normalizedOld, newPath: normalizedNew, newFileName } + })) return { success: true, diff --git a/frontend/src/modules/services/workflowParser.ts b/frontend/src/modules/services/workflowParser.ts index c279ca6..aa82aad 100644 --- a/frontend/src/modules/services/workflowParser.ts +++ b/frontend/src/modules/services/workflowParser.ts @@ -615,6 +615,33 @@ export function createWorkflowNode( description: '', }, } + case 'node-group': + return { + id: nodeId, + type, + position, + width: 400, + height: 250, + data: { + ...baseData, + collapsed: false, + }, + } + case 'skill': + return { + id: nodeId, + type, + position, + data: { + ...baseData, + skillName: '', + skillVersion: '', + skillPath: '', + skillScope: undefined, + parameters: {}, + timeoutMs: 60000, + }, + } // --- Add new node type cases here --- default: // IMPORTANT: If you see this error, add a new case to createWorkflowNode() in workflowParser.ts @@ -672,6 +699,8 @@ function getDefaultLabel(type: WorkflowNodeType): string { output: 'Output', 'web-search': 'Web Search', 'database-query': 'DB Query', + 'node-group': 'Group', + skill: 'Skill', // --- Add new node type labels here --- } return labels[type] || 'Node' diff --git a/frontend/src/modules/services/workflowTypes.ts b/frontend/src/modules/services/workflowTypes.ts index c7b50de..2306add 100644 --- a/frontend/src/modules/services/workflowTypes.ts +++ b/frontend/src/modules/services/workflowTypes.ts @@ -82,13 +82,15 @@ export type WorkflowNodeType = | 'output' | 'web-search' // Web search node: search the web via configurable provider | 'database-query' // Database query execution node + | 'skill' // Installed skill package execution + | 'node-group' // Visual grouping container for multi-node template export // --- Add new node types here --- export interface WorkflowNode { id: string type: WorkflowNodeType position: { x: number; y: number } - data: TriggerNodeData | PromptNodeData | ProviderNodeData | ConditionNodeData | LoopNodeData | ParallelNodeData | MergeNodeData | TransformerNodeData | ApiNodeData | ToolNodeData | ToolCallParserNodeData | ToolCallRouterNodeData | AgentNodeData | ChatAgentNodeData | GuardrailNodeData | CallbackNodeData | UserInputNodeData | ErrorHandlerNodeData | CommandNodeData | ClaudeCodeNodeData | WorkflowNodeData | McpToolNodeData | CodeNodeData | MemoryNodeData | OutputNodeData | WebSearchNodeData + data: TriggerNodeData | PromptNodeData | ProviderNodeData | ConditionNodeData | LoopNodeData | ParallelNodeData | MergeNodeData | TransformerNodeData | ApiNodeData | ToolNodeData | ToolCallParserNodeData | ToolCallRouterNodeData | AgentNodeData | ChatAgentNodeData | GuardrailNodeData | CallbackNodeData | UserInputNodeData | ErrorHandlerNodeData | CommandNodeData | ClaudeCodeNodeData | WorkflowNodeData | McpToolNodeData | CodeNodeData | MemoryNodeData | OutputNodeData | WebSearchNodeData | SkillNodeData /** Parent node ID for compound nodes (loop/parallel containers) */ parentId?: string /** Extent for child nodes - 'parent' constrains to parent bounds */ @@ -1118,6 +1120,32 @@ export interface McpToolNodeData extends BaseNodeData { includeInContext?: boolean } +/** + * SkillNodeData - Execute an installed skill package + * + * Skills are AI agent tasks: a .prmd prompt that orchestrates tool usage, + * optionally bundled with executable scripts. Installed to .prompd/skills/. + */ +export interface SkillNodeData extends BaseNodeData { + /** Installed skill package name (e.g. "@prompd/code-review") */ + skillName: string + + /** Installed skill version */ + skillVersion?: string + + /** Resolved path to the skill's directory on disk */ + skillPath?: string + + /** Scope of the installed skill */ + skillScope?: 'workspace' | 'user' + + /** Parameter values mapping (keys match the skill's parameter schema) */ + parameters: Record + + /** Timeout for skill execution (ms) */ + timeoutMs?: number +} + /** * CodeNodeData - Execute code snippets in various languages * diff --git a/frontend/src/modules/types.ts b/frontend/src/modules/types.ts index aa0b223..3d50445 100644 --- a/frontend/src/modules/types.ts +++ b/frontend/src/modules/types.ts @@ -22,7 +22,7 @@ export type AiGenerationMetadata = { export type Tab = { id: string name: string - type?: 'file' | 'execution' | 'chat' // Default is 'file' if not specified (.pdflow files use 'file' type with 'design' viewMode) + type?: 'file' | 'execution' | 'chat' | 'brainstorm' // Default is 'file' if not specified (.pdflow files use 'file' type with 'design' viewMode) handle?: any text: string dirty?: boolean diff --git a/frontend/src/modules/types/wizard.ts b/frontend/src/modules/types/wizard.ts index 598d215..9f7c28a 100644 --- a/frontend/src/modules/types/wizard.ts +++ b/frontend/src/modules/types/wizard.ts @@ -177,6 +177,7 @@ export interface ExecutionConfig { maxTokens?: number // Max tokens to generate (default: 4096) temperature?: number // Temperature 0-2 (default: 0.7) mode?: GenerationMode // Generation mode (default: 'default') + imageGeneration?: boolean // Enable image generation (default: true when model supports it) } /** diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index 643c309..80f7ff2 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -311,7 +311,7 @@ export const useEditorStore = create()( if (index !== -1) { const tab = state.tabs[index] // Prevent setting dirty on chat/execution tabs (they can't be saved) - if ((tab.type === 'chat' || tab.type === 'execution') && 'dirty' in updates) { + if ((tab.type === 'chat' || tab.type === 'execution' || tab.type === 'brainstorm') && 'dirty' in updates) { const { dirty, ...safeUpdates } = updates state.tabs[index] = { ...tab, ...safeUpdates } } else { diff --git a/frontend/src/stores/types.ts b/frontend/src/stores/types.ts index e6d0fde..7aa0a25 100644 --- a/frontend/src/stores/types.ts +++ b/frontend/src/stores/types.ts @@ -16,7 +16,7 @@ export interface Tab { handle?: FileSystemFileHandle filePath?: string // Full disk path for file restoration after app restart dirty?: boolean - type?: 'file' | 'execution' | 'chat' + type?: 'file' | 'execution' | 'chat' | 'brainstorm' viewMode?: 'wizard' | 'design' | 'code' readOnly?: boolean executionConfig?: any // ExecutionConfig type @@ -33,6 +33,12 @@ export interface Tab { showPreview?: boolean // Show compiled markdown preview in split view showChat?: boolean // Show AI chat pane in split view previewParams?: Record // Parameter values for preview compilation + brainstormConfig?: { + sourceFilePath: string + sourceTabId?: string + conversationId?: string + editorMode?: 'wysiwyg' | 'code' + } } /** @@ -64,7 +70,7 @@ export interface FileSystemEntry { /** * UI State for sidebar */ -export type SidebarPanel = 'explorer' | 'packages' | 'ai' | 'git' | 'history' | 'resources' +export type SidebarPanel = 'explorer' | 'packages' | 'ai' | 'git' | 'history' | 'resources' | 'library' /** * Modal types @@ -73,12 +79,14 @@ export type ModalType = | 'apiKeySettings' | 'localStorage' | 'publish' + | 'publish-resource' | 'settings' | 'about' | 'aiGenerate' | 'fileChanges' | 'deployment' | 'deploy-workflow' + | 'newProject' | null /** @@ -99,6 +107,7 @@ export interface BuildError { message: string line?: number column?: number + severity?: 'error' | 'warning' | 'info' | 'hint' } /** @@ -115,3 +124,18 @@ export interface BuildOutput { size?: number timestamp?: number } + +/** + * A single package build record for the Packages tab history + */ +export interface PackageBuildRecord { + id: string + status: 'success' | 'error' + message: string + fileName?: string + outputPath?: string + fileCount?: number + size?: number + timestamp: number + errors?: BuildError[] +} diff --git a/frontend/src/stores/uiStore.ts b/frontend/src/stores/uiStore.ts index 8dfbae0..1c7cad3 100644 --- a/frontend/src/stores/uiStore.ts +++ b/frontend/src/stores/uiStore.ts @@ -6,7 +6,7 @@ import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' -import type { SidebarPanel, ModalType, Toast, BuildOutput } from './types' +import type { SidebarPanel, ModalType, Toast, BuildOutput, PackageBuildRecord } from './types' import { getApiBaseUrl, waitForUserSync, isUserSynced } from '../modules/services/apiConfig' import { configService } from '../modules/services/configService' @@ -15,6 +15,17 @@ import { configService } from '../modules/services/configService' // and Zustand's set() is async, so state checks can race let isInitializingProviders = false +/** + * Infer whether a model supports extended thinking based on model ID. + * Used as fallback when the backend/config doesn't provide the flag. + * Currently only Anthropic Sonnet/Opus models support thinking. + */ +function inferSupportsThinking(providerId: string, modelId: string): boolean { + if (providerId !== 'anthropic') return false + const id = modelId.toLowerCase() + return id.includes('sonnet') || id.includes('opus') +} + /** * Model info with pricing */ @@ -27,6 +38,7 @@ export interface ModelWithPricing { supportsVision?: boolean supportsTools?: boolean supportsImageGeneration?: boolean + supportsThinking?: boolean } /** @@ -125,6 +137,7 @@ interface UIState { // Build output panel buildOutput: BuildOutput + packageBuildHistory: PackageBuildRecord[] showBuildPanel: boolean buildPanelPinned: boolean @@ -213,6 +226,8 @@ interface UIActions { // Build output panel setBuildOutput: (output: Partial) => void clearBuildOutput: () => void + addPackageBuildRecord: (record: PackageBuildRecord) => void + clearPackageBuildHistory: () => void setShowBuildPanel: (show: boolean) => void toggleBuildPanel: () => void setBuildPanelPinned: (pinned: boolean) => void @@ -284,6 +299,7 @@ export const useUIStore = create()( selectedEnvFile: null, toasts: [], buildOutput: { status: 'idle', message: '' }, + packageBuildHistory: [], showBuildPanel: false, buildPanelPinned: false, showWorkflowPanel: false, @@ -434,7 +450,7 @@ export const useUIStore = create()( let locallyConfiguredProviders: string[] = [] let localCustomProviders: Array<{ name: string; displayName: string; models: { id: string; name: string; contextWindow?: number }[]; isCustom: boolean }> = [] // Raw custom provider config for capability flags - let customProviderConfigs: Record = {} + let customProviderConfigs: Record = {} if ((window as any).electronAPI?.isElectron) { try { const { localExecutor } = await import('../modules/services/localExecutor') @@ -494,9 +510,9 @@ export const useUIStore = create()( hasKey: true, isCustom: false, models: [ - { model: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', inputPrice: 1.00, outputPrice: 5.00, supportsImageGeneration: false }, - { model: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', inputPrice: 3.00, outputPrice: 15.00, supportsImageGeneration: false }, - { model: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', inputPrice: 5.00, outputPrice: 25.00, supportsImageGeneration: false } + { model: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', inputPrice: 1.00, outputPrice: 5.00, supportsImageGeneration: false, supportsThinking: false }, + { model: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', inputPrice: 3.00, outputPrice: 15.00, supportsImageGeneration: false, supportsThinking: true }, + { model: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', inputPrice: 5.00, outputPrice: 25.00, supportsImageGeneration: false, supportsThinking: true } ] }, { @@ -541,7 +557,8 @@ export const useUIStore = create()( contextWindow: caps?.context_window, supportsVision: caps?.supports_vision, supportsTools: caps?.supports_tools, - supportsImageGeneration: caps?.supports_image_generation + supportsImageGeneration: caps?.supports_image_generation, + supportsThinking: caps?.supports_thinking ?? inferSupportsThinking(cp.name, m.id) } }) }] @@ -607,7 +624,8 @@ export const useUIStore = create()( contextWindow: m.contextWindow, supportsVision: m.supportsVision, supportsTools: m.supportsTools, - supportsImageGeneration: m.supportsImageGeneration || false + supportsImageGeneration: m.supportsImageGeneration || false, + supportsThinking: m.supportsThinking ?? inferSupportsThinking(p.providerId, m.model) })) // If API returned empty models, use default models for known providers @@ -661,7 +679,8 @@ export const useUIStore = create()( contextWindow: caps?.context_window, supportsVision: caps?.supports_vision, supportsTools: caps?.supports_tools, - supportsImageGeneration: caps?.supports_image_generation + supportsImageGeneration: caps?.supports_image_generation, + supportsThinking: caps?.supports_thinking ?? inferSupportsThinking(cp.name, m.id) } }) }) @@ -872,6 +891,17 @@ export const useUIStore = create()( state.buildOutput = { status: 'idle', message: '' } }), + addPackageBuildRecord: (record) => set((state) => { + state.packageBuildHistory.unshift(record) + if (state.packageBuildHistory.length > 50) { + state.packageBuildHistory = state.packageBuildHistory.slice(0, 50) + } + }), + + clearPackageBuildHistory: () => set((state) => { + state.packageBuildHistory = [] + }), + setShowBuildPanel: (show) => set((state) => { state.showBuildPanel = show }), diff --git a/frontend/src/stores/workflowStore.ts b/frontend/src/stores/workflowStore.ts index b50f30f..b75c34e 100644 --- a/frontend/src/stores/workflowStore.ts +++ b/frontend/src/stores/workflowStore.ts @@ -102,7 +102,7 @@ function cloneEdges(edges: WorkflowCanvasEdge[]): WorkflowCanvasEdge[] { } // Container node types that can have children -const CONTAINER_TYPES = ['loop', 'parallel', 'tool-call-router', 'chat-agent'] +const CONTAINER_TYPES = ['loop', 'parallel', 'tool-call-router', 'chat-agent', 'node-group'] // Node types that can be dropped into tool-call-router const TOOL_ROUTER_ALLOWED_CHILDREN = ['tool', 'tool-call-parser'] @@ -149,6 +149,11 @@ function canNodeBeDroppedIntoContainer( return TOOL_ROUTER_ALLOWED_CHILDREN.includes(nodeType) } + // Group accepts any node type except other groups (purely visual container) + if (containerType === 'node-group') { + return nodeType !== 'node-group' + } + // Loop and parallel accept any non-container node if (containerType === 'loop' || containerType === 'parallel') { return !CONTAINER_TYPES.includes(nodeType) @@ -477,6 +482,7 @@ interface WorkflowStoreState { addNodeFromTemplate: (templateData: Record, position: { x: number; y: number }, onEdgeId?: string) => string | null toggleNodeDisabled: (nodeId: string) => void ejectChildNodes: (containerId: string) => void + groupNodes: (nodeIds: string[]) => string | null // Context menu operations showContextMenu: (type: 'node' | 'edge' | 'canvas', position: { x: number; y: number }, nodeId?: string, edgeId?: string) => void @@ -504,6 +510,7 @@ interface WorkflowStoreState { setCheckpoints: (checkpoints: CheckpointEvent[]) => void setPromptsSent: (prompts: PromptSentInfo[]) => void clearExecutionState: () => void + addToExecutionHistory: (entry: ExecutionHistoryEntry) => void loadExecutionFromHistory: (id: string) => void clearExecutionHistory: () => void @@ -744,6 +751,7 @@ export const useWorkflowStore = create()( if (!node) return const isContainer = CONTAINER_TYPES.includes(node.type || '') + const isGroupableContainer = isContainer && node.type !== 'node-group' const currentParentId = node.parentId // Calculate absolute position (needed for both container detection and docking) @@ -768,8 +776,8 @@ export const useWorkflowStore = create()( absolutePosition = position } - // Container-into-container parent/child detection (skip for containers) - if (!isContainer) { + // Container-into-container parent/child detection (skip for containers, unless they can be grouped) + if (!isContainer || isGroupableContainer) { // Find container at position, passing node type for filtering const containerId = findContainerAtPosition(state.nodes, absolutePosition, nodeId, node.type) @@ -784,27 +792,23 @@ export const useWorkflowStore = create()( state.dragState.hoverContainerId = containerId state.dragState.exitingContainerId = prevHoverId && prevHoverId !== containerId ? prevHoverId : null } + } else { + // Non-groupable container — skip parent/child detection } // Check for dock targets (for ALL dockable node types, including containers) const nodeType = node.type || '' if (DOCKABLE_NODE_TYPES.includes(nodeType as WorkflowNodeType)) { - console.log('[onNodeDrag] Dragging dockable node:', nodeType, 'ID:', nodeId) - console.log('[Docking Check] Looking for dock target for', nodeType, 'at absolute position:', absolutePosition) const dockTarget = findDockTargetAtPosition(state.nodes, absolutePosition, nodeType, DEFAULT_SNAP_THRESHOLD) if (dockTarget) { - console.log('[Docking] ✅ Found dock target:', dockTarget, 'at position:', absolutePosition) state.dockingState = { draggingNodeId: nodeId, hoveredDockTarget: dockTarget, snapThreshold: DEFAULT_SNAP_THRESHOLD, } } else { - console.log('[Docking] ❌ No dock target found at position:', absolutePosition) if (state.dockingState?.draggingNodeId === nodeId) { - // Clear docking state if we moved away from dock target - console.log('[Docking] Clearing docking state') state.dockingState = null } } @@ -950,8 +954,11 @@ export const useWorkflowStore = create()( // Clear docking state state.dockingState = null - // Skip if this node IS a container - if (CONTAINER_TYPES.includes(rfNode.type || '')) { + // Skip if this node is a group container (can't nest groups) + // Other container types (loop, parallel, chat-agent, etc.) are allowed through + // so they can be dropped into group nodes — findContainerAtPosition + canNodeBeDroppedIntoContainer + // will correctly filter which targets accept them + if (rfNode.type === 'node-group') { return } @@ -1222,9 +1229,8 @@ export const useWorkflowStore = create()( const savedWidth = currentNode?.width const savedHeight = currentNode?.height - // Collapsed dimensions for container nodes + // Collapsed width for container nodes (height is auto-determined by content) const COLLAPSED_WIDTH = 180 - const COLLAPSED_HEIGHT = 120 // Approximate height for collapsed view with metadata // Update ALL nodes - container gets new data, children get hidden state // Creating new objects for all affected nodes to trigger React Flow re-render @@ -1246,18 +1252,25 @@ export const useWorkflowStore = create()( } if (isCollapsed) { - // Set to collapsed size + // Set collapsed width; delete height so the node auto-sizes + // from its content. ContainerNode's useEffect will + // DOM-measure the actual height and set `measured` via RAF. newNode.width = COLLAPSED_WIDTH - newNode.height = COLLAPSED_HEIGHT + delete newNode.height } else { // Restore to saved dimensions or remove to let node auto-size const savedW = (node.data as Record)?._savedWidth as number | undefined const savedH = (node.data as Record)?._savedHeight as number | undefined - if (savedW) newNode.width = savedW - else delete newNode.width + if (savedW) { + newNode.width = savedW + } else { + delete newNode.width + } if (savedH) newNode.height = savedH else delete newNode.height } + // Always delete measured so React Flow re-measures from DOM + delete (newNode as Record).measured return newNode } @@ -1534,8 +1547,9 @@ export const useWorkflowStore = create()( // Add a node from a saved template. If onEdgeId is provided, insert on that edge // (remove original edge, wire source -> template root -> target) in a single atomic operation. addNodeFromTemplate: (templateData, position, onEdgeId?) => { - // Support both nested (node: {}) and legacy flat structure - const nodeInfo = (templateData.node || templateData) as Record + // Read node info from the type-specific 'node-template' section + const ntSection = templateData['node-template'] as Record | undefined + const nodeInfo = ntSection?.node as Record | undefined if (!nodeInfo?.nodeType) return null // If inserting on an edge, resolve midpoint and validate @@ -1864,6 +1878,106 @@ export const useWorkflowStore = create()( }) }, + groupNodes: (nodeIds) => { + if (nodeIds.length < 2) return null + get().pushHistory() + + const state = get() + // Only group top-level nodes (skip nodes already inside a container) + const nodesToGroup = state.nodes.filter( + n => nodeIds.includes(n.id) && !n.parentId + ) + if (nodesToGroup.length < 2) return null + + // Compute bounding box of selected nodes + const PADDING = 40 + const HEADER_HEIGHT = 60 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const node of nodesToGroup) { + const w = (node.width as number) || 200 + const h = (node.height as number) || 60 + if (node.position.x < minX) minX = node.position.x + if (node.position.y < minY) minY = node.position.y + if (node.position.x + w > maxX) maxX = node.position.x + w + if (node.position.y + h > maxY) maxY = node.position.y + h + } + + const groupX = minX - PADDING + const groupY = minY - HEADER_HEIGHT - PADDING + const groupWidth = (maxX - minX) + PADDING * 2 + const groupHeight = (maxY - minY) + HEADER_HEIGHT + PADDING * 2 + + // Create group node + const groupNode = createWorkflowNode('node-group', { x: groupX, y: groupY }) + const groupId = groupNode.id + + set(state => { + // Add group to workflow file + groupNode.width = groupWidth + groupNode.height = groupHeight + if (state.workflowFile) { + state.workflowFile.nodes.push(groupNode) + } + + // Add group to React Flow nodes (before children) + const rfGroupNode: WorkflowCanvasNode = { + id: groupId, + type: 'node-group', + position: { x: groupX, y: groupY }, + data: groupNode.data, + width: groupWidth, + height: groupHeight, + } + state.nodes.push(rfGroupNode) + + // Re-parent each selected node into the group + const childIds = nodesToGroup.map(n => n.id) + state.nodes = state.nodes.map(node => { + if (childIds.includes(node.id)) { + const relativePosition = { + x: node.position.x - groupX, + y: node.position.y - groupY, + } + return { + ...node, + parentId: groupId, + extent: 'parent' as const, + position: relativePosition, + } + } + return node + }) + + // Ensure parent appears before children in the array + const children = state.nodes.filter(n => childIds.includes(n.id)) + const others = state.nodes.filter(n => !childIds.includes(n.id)) + // Re-build: everything before group (inclusive), then children, then rest + const groupInOthers = others.findIndex(n => n.id === groupId) + const before = others.slice(0, groupInOthers + 1) + const after = others.slice(groupInOthers + 1) + state.nodes = [...before, ...children, ...after] + + // Update workflow file nodes with parent info + if (state.workflowFile) { + for (const childId of childIds) { + const fileNode = state.workflowFile.nodes.find(n => n.id === childId) + if (fileNode) { + fileNode.parentId = groupId + const rfNode = state.nodes.find(n => n.id === childId) + if (rfNode) { + fileNode.position = { ...rfNode.position } + } + } + } + } + + state.isDirty = true + state.selectedNodeId = groupId + }) + + return groupId + }, + // ========================================================================= // Node Docking Operations // ========================================================================= @@ -2494,6 +2608,15 @@ export const useWorkflowStore = create()( }) }, + addToExecutionHistory: (entry) => { + set(state => { + state.executionHistory.unshift(entry) + if (state.executionHistory.length > 50) { + state.executionHistory = state.executionHistory.slice(0, 50) + } + }) + }, + loadExecutionFromHistory: (id) => { const state = get() const entry = state.executionHistory.find(e => e.id === id) diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index da13de7..a3dd688 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -563,6 +563,12 @@ body { background: var(--bg); color: var(--text); font: 14px/1.4 Inter, system-u opacity: 0; font-size: 12px; margin-left: 2px; + padding: 0; + border: none; + background: none; + cursor: pointer; + font-family: inherit; + line-height: 1; transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, transform 0.1s ease; } .tab:hover .close, @@ -789,6 +795,15 @@ select.input { } } +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + .animate-spin { animation: spin 1s linear infinite; } @@ -1131,6 +1146,49 @@ select.input { flex-shrink: 0; } +.build-output-error-item .warning-icon { + color: #e5a50a; + flex-shrink: 0; +} + +/* Severity filter bar */ +.build-output-filter-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-bottom: 1px solid var(--border-color, #333); +} + +.build-output-filter-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border: none; + background: transparent; + color: var(--muted); + font-size: 11px; + cursor: pointer; + border-radius: 3px; + opacity: 0.5; + transition: opacity 0.15s ease, background 0.15s ease; +} + +.build-output-filter-btn:hover { + background: var(--hover); + opacity: 0.8; +} + +.build-output-filter-btn.active { + opacity: 1; + color: var(--error); +} + +.build-output-filter-btn.warning.active { + color: #e5a50a; +} + .build-output-error-item .error-file { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; @@ -1160,6 +1218,48 @@ select.input { opacity: 1; } +/* Error context menu */ +.error-context-menu { + position: fixed; + z-index: 10000; + min-width: 180px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.error-context-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + background: none; + border: none; + color: var(--text); + font-size: 12px; + cursor: pointer; + text-align: left; + white-space: nowrap; +} + +.error-context-menu-item:hover { + background: var(--hover); +} + +.error-context-menu-item svg { + flex-shrink: 0; + opacity: 0.7; +} + +.error-context-menu-separator { + height: 1px; + background: var(--border); + margin: 4px 0; +} + /* Command output details (from /validate, /explain, etc.) */ .build-output-command-details { margin: 8px 12px; @@ -1909,6 +2009,10 @@ select.input { border-radius: 8px; } +.bottom-panel-tab-badge.warning { + background: #b8860b; +} + .bottom-panel-tab-indicator { width: 6px; height: 6px; @@ -2003,3 +2107,12 @@ select.input { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes conversationDropdownFadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +/* Right pane content reveal — pure CSS so React re-renders can't restart it */ +@keyframes splitPaneReveal { + 0% { opacity: 0; } + 100% { opacity: 1; } +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index d6e972a..819f13f 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/electron.d.ts","./src/main.tsx","./src/vite-env.d.ts","./src/constants/app.ts","./src/modules/app.tsx","./src/modules/types.ts","./src/modules/auth/clerkwrapper.tsx","./src/modules/components/aboutmodal.tsx","./src/modules/components/aigeneratemodal.tsx","./src/modules/components/approvaldialog.tsx","./src/modules/components/approvaldiffview.tsx","./src/modules/components/bottompaneltabs.tsx","./src/modules/components/buildoutputpanel.tsx","./src/modules/components/closeworkspacedialog.tsx","./src/modules/components/codeeditor.tsx","./src/modules/components/commandpalette.tsx","./src/modules/components/confirmdialog.tsx","./src/modules/components/contentminimap.tsx","./src/modules/components/contentsections.tsx","./src/modules/components/conversationsidebar.tsx","./src/modules/components/conversationalchat.tsx","./src/modules/components/customprovidermodal.tsx","./src/modules/components/dependencyversionselect.tsx","./src/modules/components/diffview.tsx","./src/modules/components/envfileselector.tsx","./src/modules/components/errorboundary.tsx","./src/modules/components/executioncontrols.tsx","./src/modules/components/executionhistorypanel.tsx","./src/modules/components/filechangesmodal.tsx","./src/modules/components/fileselector.tsx","./src/modules/components/firsttimesetupwizard.tsx","./src/modules/components/generationcontrols.tsx","./src/modules/components/helpchatpopover.tsx","./src/modules/components/hotkeysettings.tsx","./src/modules/components/inheritsmanager.tsx","./src/modules/components/inlinehints.tsx","./src/modules/components/localstoragemodal.tsx","./src/modules/components/markdownpreview.tsx","./src/modules/components/mcpdependencydialog.tsx","./src/modules/components/nunjuckssnippetmenu.tsx","./src/modules/components/packageselector.tsx","./src/modules/components/planapprovaldialog.tsx","./src/modules/components/planreviewmodal.tsx","./src/modules/components/prefixselector.tsx","./src/modules/components/prompdicon.tsx","./src/modules/components/prompdjsondesignview.tsx","./src/modules/components/prompdjsoneditor.tsx","./src/modules/components/prompdpreviewmodal.tsx","./src/modules/components/prompdsessionhistory.tsx","./src/modules/components/providermodelselector.tsx","./src/modules/components/publishmodal.tsx","./src/modules/components/resourcepanel.tsx","./src/modules/components/restorestateprompt.tsx","./src/modules/components/sectionadder.tsx","./src/modules/components/sectioneditor.tsx","./src/modules/components/settingsmodal.tsx","./src/modules/components/sidebarpanelheader.tsx","./src/modules/components/slashcommandmenu.tsx","./src/modules/components/taginput.tsx","./src/modules/components/titlebar.tsx","./src/modules/components/toastcontainer.tsx","./src/modules/components/usingdeclarationslist.tsx","./src/modules/components/versioninput.tsx","./src/modules/components/versionnumberinput.tsx","./src/modules/components/versionsegmentinput.tsx","./src/modules/components/welcomeview.tsx","./src/modules/components/wysiwygeditor.tsx","./src/modules/components/wysiwygtoolbar.tsx","./src/modules/components/xmldesignview.tsx","./src/modules/components/common/jsontreeviewer.tsx","./src/modules/components/common/variablereference.tsx","./src/modules/components/deployment/deployworkflowmodal.tsx","./src/modules/components/deployment/deploymentmodal.tsx","./src/modules/components/deployment/parameterinputmodal.tsx","./src/modules/components/scheduler/scheduledialog.tsx","./src/modules/components/scheduler/schedulerpanel.tsx","./src/modules/components/settings/deploymentsettings.tsx","./src/modules/components/settings/mcpserversettings.tsx","./src/modules/components/settings/serviceandwebhooksettings.tsx","./src/modules/components/settings/servicesettings.tsx","./src/modules/components/settings/webhooksettings.tsx","./src/modules/components/workflow/contextmenu.tsx","./src/modules/components/workflow/croneditordialog.tsx","./src/modules/components/workflow/croninput.tsx","./src/modules/components/workflow/fileeditormodal.tsx","./src/modules/components/workflow/nodeerrorboundary.tsx","./src/modules/components/workflow/nodepalette.tsx","./src/modules/components/workflow/nodequickactions.tsx","./src/modules/components/workflow/outputviewdialog.tsx","./src/modules/components/workflow/prompdviewermodal.tsx","./src/modules/components/workflow/propertiespanel.backup.tsx","./src/modules/components/workflow/propertiespanel.tsx","./src/modules/components/workflow/savetemplatedialog.tsx","./src/modules/components/workflow/unifiedworkflowtoolbar.tsx","./src/modules/components/workflow/userinputdialog.tsx","./src/modules/components/workflow/workflowexecutionpanel.tsx","./src/modules/components/workflow/workflowparameterspanel.tsx","./src/modules/components/workflow/workflowrundialog.tsx","./src/modules/components/workflow/workflowsettingsdialog.tsx","./src/modules/components/workflow/workflowtoolbar.tsx","./src/modules/components/workflow/nodecolors.ts","./src/modules/components/workflow/edges/actionedge.tsx","./src/modules/components/workflow/edges/index.ts","./src/modules/components/workflow/hooks/usedocking.ts","./src/modules/components/workflow/nodes/agentnode.tsx","./src/modules/components/workflow/nodes/agentnodeproperties.tsx","./src/modules/components/workflow/nodes/callbacknode.tsx","./src/modules/components/workflow/nodes/callbacknodeproperties.tsx","./src/modules/components/workflow/nodes/chatagentnode.tsx","./src/modules/components/workflow/nodes/chatagentnodeproperties.tsx","./src/modules/components/workflow/nodes/claudecodenode.tsx","./src/modules/components/workflow/nodes/claudecodenodeproperties.tsx","./src/modules/components/workflow/nodes/codenode.tsx","./src/modules/components/workflow/nodes/codenodeproperties.tsx","./src/modules/components/workflow/nodes/commandnode.tsx","./src/modules/components/workflow/nodes/commandnodeproperties.tsx","./src/modules/components/workflow/nodes/conditionnode.tsx","./src/modules/components/workflow/nodes/conditionnodeproperties.tsx","./src/modules/components/workflow/nodes/containernode.tsx","./src/modules/components/workflow/nodes/databasequerynode.tsx","./src/modules/components/workflow/nodes/databasequerynodeproperties.tsx","./src/modules/components/workflow/nodes/dockednodepreview.tsx","./src/modules/components/workflow/nodes/errorhandlernode.tsx","./src/modules/components/workflow/nodes/errorhandlernodeproperties.tsx","./src/modules/components/workflow/nodes/errorhandlerpreviewicon.tsx","./src/modules/components/workflow/nodes/guardrailnode.tsx","./src/modules/components/workflow/nodes/guardrailnodeproperties.tsx","./src/modules/components/workflow/nodes/loopnode.tsx","./src/modules/components/workflow/nodes/loopnodeproperties.tsx","./src/modules/components/workflow/nodes/mcptoolnode.tsx","./src/modules/components/workflow/nodes/mcptoolnodeproperties.tsx","./src/modules/components/workflow/nodes/memorynode.tsx","./src/modules/components/workflow/nodes/memorynodeproperties.tsx","./src/modules/components/workflow/nodes/mergenode.tsx","./src/modules/components/workflow/nodes/mergenodeproperties.tsx","./src/modules/components/workflow/nodes/nodeexecutionfooter.tsx","./src/modules/components/workflow/nodes/nodeinspectmodal.tsx","./src/modules/components/workflow/nodes/outputnode.tsx","./src/modules/components/workflow/nodes/outputnodeproperties.tsx","./src/modules/components/workflow/nodes/parallelbroadcastnode.tsx","./src/modules/components/workflow/nodes/parallelforknode.tsx","./src/modules/components/workflow/nodes/parallelnode.tsx","./src/modules/components/workflow/nodes/parallelnodeproperties.tsx","./src/modules/components/workflow/nodes/promptnode.tsx","./src/modules/components/workflow/nodes/promptnodeproperties.tsx","./src/modules/components/workflow/nodes/providernode.tsx","./src/modules/components/workflow/nodes/providernodeproperties.tsx","./src/modules/components/workflow/nodes/toolcallparsernode.tsx","./src/modules/components/workflow/nodes/toolcallparsernodeproperties.tsx","./src/modules/components/workflow/nodes/toolcallrouternode.tsx","./src/modules/components/workflow/nodes/toolcallrouternodeproperties.tsx","./src/modules/components/workflow/nodes/toolnode.tsx","./src/modules/components/workflow/nodes/toolnodeproperties.tsx","./src/modules/components/workflow/nodes/toolrouterdockpreview.tsx","./src/modules/components/workflow/nodes/transformnode.tsx","./src/modules/components/workflow/nodes/transformernodeproperties.tsx","./src/modules/components/workflow/nodes/triggernode.tsx","./src/modules/components/workflow/nodes/triggernodeproperties.tsx","./src/modules/components/workflow/nodes/userinputnode.tsx","./src/modules/components/workflow/nodes/userinputnodeproperties.tsx","./src/modules/components/workflow/nodes/websearchnode.tsx","./src/modules/components/workflow/nodes/websearchnodeproperties.tsx","./src/modules/components/workflow/nodes/workflownode.tsx","./src/modules/components/workflow/nodes/workflownodeproperties.tsx","./src/modules/components/workflow/nodes/index.ts","./src/modules/components/workflow/panels/addconnectiondialog.tsx","./src/modules/components/workflow/panels/connectionsettingsdialog.tsx","./src/modules/components/workflow/panels/connectionspanel.tsx","./src/modules/components/workflow/panels/mcpserversetupflow.tsx","./src/modules/components/workflow/panels/triggerconfigdialog.tsx","./src/modules/components/workflow/panels/triggermanagementpanel.tsx","./src/modules/components/workflow/shared/hooks/usenodeconnections.ts","./src/modules/components/workflow/shared/hooks/useprovidernodes.ts","./src/modules/components/workflow/shared/hooks/usesourcesearch.ts","./src/modules/components/workflow/shared/property-components/collapsiblesection.tsx","./src/modules/components/workflow/shared/property-components/connectionselector.tsx","./src/modules/components/workflow/shared/property-components/errorhandlerselector.tsx","./src/modules/components/workflow/shared/property-components/llmproviderconfig.tsx","./src/modules/components/workflow/shared/property-components/sourceselector.tsx","./src/modules/components/workflow/shared/services/filesearchservice.ts","./src/modules/components/workflow/shared/styles/propertystyles.ts","./src/modules/contexts/hotkeycontext.tsx","./src/modules/editor/activitybar.tsx","./src/modules/editor/aichatpanel.tsx","./src/modules/editor/chattab.tsx","./src/modules/editor/compiledpreview.tsx","./src/modules/editor/designview.tsx","./src/modules/editor/editorheader.tsx","./src/modules/editor/executionresultmodal.tsx","./src/modules/editor/fileexplorer.tsx","./src/modules/editor/gitpanel.tsx","./src/modules/editor/localpackagemodal.tsx","./src/modules/editor/modal.tsx","./src/modules/editor/packagedetailsmodal.tsx","./src/modules/editor/packagepanel.tsx","./src/modules/editor/prompdeditor.tsx","./src/modules/editor/prompdexecutiontab.tsx","./src/modules/editor/spliteditor.tsx","./src/modules/editor/splitviewtoggles.tsx","./src/modules/editor/statusbar.tsx","./src/modules/editor/tabsbar.tsx","./src/modules/editor/workflowcanvas.tsx","./src/modules/hooks/index.ts","./src/modules/hooks/useagentchat.dont-use.ts","./src/modules/hooks/useagentmode.ts","./src/modules/hooks/usemcptools.ts","./src/modules/hooks/usetabmanager.ts","./src/modules/integrations/prompdeditorintegration.ts","./src/modules/lib/editorllmclient.ts","./src/modules/lib/compilevalidation.ts","./src/modules/lib/editorconfig.ts","./src/modules/lib/executionutils.ts","./src/modules/lib/formatters.ts","./src/modules/lib/intellisense.ts","./src/modules/lib/languagedetection.ts","./src/modules/lib/logger.ts","./src/modules/lib/markdowntransform.ts","./src/modules/lib/monacoconfig.ts","./src/modules/lib/monacovariabledecorations.ts","./src/modules/lib/prompdautofix.ts","./src/modules/lib/prompdparser.ts","./src/modules/lib/snippets.ts","./src/modules/lib/storage.ts","./src/modules/lib/templates.ts","./src/modules/lib/textmate.ts","./src/modules/lib/intellisense/codeactions.ts","./src/modules/lib/intellisense/completions.ts","./src/modules/lib/intellisense/context.ts","./src/modules/lib/intellisense/crossreference.ts","./src/modules/lib/intellisense/envcache.ts","./src/modules/lib/intellisense/filters.ts","./src/modules/lib/intellisense/hover.ts","./src/modules/lib/intellisense/index.ts","./src/modules/lib/intellisense/promptpatterns.ts","./src/modules/lib/intellisense/registrysync.ts","./src/modules/lib/intellisense/testcodeactions.ts","./src/modules/lib/intellisense/types.ts","./src/modules/lib/intellisense/utils.ts","./src/modules/lib/intellisense/validation.ts","./src/modules/lib/tiptap/nunjucksextension.ts","./src/modules/services/localllmclient.ts","./src/modules/services/agenttools.ts","./src/modules/services/agentxmlparser.ts","./src/modules/services/aiapi.ts","./src/modules/services/api.ts","./src/modules/services/apiconfig.ts","./src/modules/services/chatmodesapi.ts","./src/modules/services/configservice.ts","./src/modules/services/contextwindowresolver.ts","./src/modules/services/conversationstorage.ts","./src/modules/services/conversationalai.ts","./src/modules/services/diffutils.ts","./src/modules/services/editorservice.ts","./src/modules/services/electronfetch.ts","./src/modules/services/envloader.ts","./src/modules/services/executionrouter.ts","./src/modules/services/executionservice.ts","./src/modules/services/filecontextbuilder.ts","./src/modules/services/hotkeymanager.ts","./src/modules/services/imagestorage.ts","./src/modules/services/llmclientrouter.ts","./src/modules/services/localcompiler.ts","./src/modules/services/localexecutor.ts","./src/modules/services/localprojectstorage.ts","./src/modules/services/memoryservice.ts","./src/modules/services/namespacesapi.ts","./src/modules/services/nodetemplateservice.ts","./src/modules/services/nodetemplatetypes.ts","./src/modules/services/nodetyperegistry.ts","./src/modules/services/onboardingservice.ts","./src/modules/services/packagecache.ts","./src/modules/services/packageservice.ts","./src/modules/services/parameterextraction.ts","./src/modules/services/prompdsettings.ts","./src/modules/services/registryapi.ts","./src/modules/services/registrydiscovery.ts","./src/modules/services/slashcommands.ts","./src/modules/services/slashcommandslocal.ts","./src/modules/services/slashcommandsremote.ts","./src/modules/services/snoweffect.ts","./src/modules/services/startupapi.ts","./src/modules/services/toolexecutor.ts","./src/modules/services/usagetracker.ts","./src/modules/services/workflowexecutor.ts","./src/modules/services/workflowparser.ts","./src/modules/services/workflowtypes.ts","./src/modules/services/workflowvalidator.ts","./src/modules/services/workspaceservice.ts","./src/modules/services/providers/base.ts","./src/modules/services/providers/factory.ts","./src/modules/services/providers/index.ts","./src/modules/services/providers/types.ts","./src/modules/types/hotkeys.ts","./src/modules/types/index.d.ts","./src/modules/types/wizard.ts","./src/modules/wizard/guidedpromptwizard.tsx","./src/modules/wizard/steps/baseselectionstep.tsx","./src/modules/wizard/steps/packagesearchstep.tsx","./src/stores/editorstore.ts","./src/stores/index.ts","./src/stores/types.ts","./src/stores/uistore.ts","./src/stores/wizardstore.ts","./src/stores/workflowstore.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/electron.d.ts","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/services.test.ts","./src/__tests__/setup.ts","./src/__tests__/stores.test.ts","./src/constants/app.ts","./src/modules/app.tsx","./src/modules/types.ts","./src/modules/auth/clerkwrapper.tsx","./src/modules/components/aboutmodal.tsx","./src/modules/components/aigeneratemodal.tsx","./src/modules/components/approvaldialog.tsx","./src/modules/components/approvaldiffview.tsx","./src/modules/components/bottompaneltabs.tsx","./src/modules/components/buildoutputpanel.tsx","./src/modules/components/closeworkspacedialog.tsx","./src/modules/components/codeeditor.tsx","./src/modules/components/commandpalette.tsx","./src/modules/components/confirmdialog.tsx","./src/modules/components/contentminimap.tsx","./src/modules/components/contentsections.tsx","./src/modules/components/conversationsidebar.tsx","./src/modules/components/conversationalchat.tsx","./src/modules/components/customprovidermodal.tsx","./src/modules/components/dependencyversionselect.tsx","./src/modules/components/diffview.tsx","./src/modules/components/envfileselector.tsx","./src/modules/components/errorboundary.tsx","./src/modules/components/executioncontrols.tsx","./src/modules/components/executionhistorypanel.tsx","./src/modules/components/filechangesmodal.tsx","./src/modules/components/fileselector.tsx","./src/modules/components/firsttimesetupwizard.tsx","./src/modules/components/generationcontrols.tsx","./src/modules/components/helpchatpopover.tsx","./src/modules/components/hotkeysettings.tsx","./src/modules/components/inheritsmanager.tsx","./src/modules/components/inlinehints.tsx","./src/modules/components/localstoragemodal.tsx","./src/modules/components/markdownpreview.tsx","./src/modules/components/mcpdependencydialog.tsx","./src/modules/components/newfiledialog.tsx","./src/modules/components/newprojectmodal.tsx","./src/modules/components/nunjuckssnippetmenu.tsx","./src/modules/components/packageselector.tsx","./src/modules/components/planapprovaldialog.tsx","./src/modules/components/planreviewmodal.tsx","./src/modules/components/prefixselector.tsx","./src/modules/components/prompdicon.tsx","./src/modules/components/prompdjsondesignview.tsx","./src/modules/components/prompdjsoneditor.tsx","./src/modules/components/prompdpreviewmodal.tsx","./src/modules/components/prompdsessionhistory.tsx","./src/modules/components/providermodelselector.tsx","./src/modules/components/publishmodal.tsx","./src/modules/components/publishresourcemodal.tsx","./src/modules/components/resourcepanel.tsx","./src/modules/components/restorestateprompt.tsx","./src/modules/components/sectionadder.tsx","./src/modules/components/sectioneditor.tsx","./src/modules/components/settingsmodal.tsx","./src/modules/components/sidebarpanelheader.tsx","./src/modules/components/slashcommandmenu.tsx","./src/modules/components/taginput.tsx","./src/modules/components/titlebar.tsx","./src/modules/components/toastcontainer.tsx","./src/modules/components/usingdeclarationslist.tsx","./src/modules/components/versioninput.tsx","./src/modules/components/versionnumberinput.tsx","./src/modules/components/versionsegmentinput.tsx","./src/modules/components/welcomeview.tsx","./src/modules/components/wysiwygeditor.tsx","./src/modules/components/wysiwygtoolbar.tsx","./src/modules/components/xmldesignview.tsx","./src/modules/components/common/jsontreeviewer.tsx","./src/modules/components/common/variablereference.tsx","./src/modules/components/deployment/deployworkflowmodal.tsx","./src/modules/components/deployment/deploymentmodal.tsx","./src/modules/components/deployment/parameterinputmodal.tsx","./src/modules/components/scheduler/scheduledialog.tsx","./src/modules/components/scheduler/schedulerpanel.tsx","./src/modules/components/settings/deploymentsettings.tsx","./src/modules/components/settings/mcpserversettings.tsx","./src/modules/components/settings/serviceandwebhooksettings.tsx","./src/modules/components/settings/servicesettings.tsx","./src/modules/components/settings/webhooksettings.tsx","./src/modules/components/workflow/contextmenu.tsx","./src/modules/components/workflow/croneditordialog.tsx","./src/modules/components/workflow/croninput.tsx","./src/modules/components/workflow/fileeditormodal.tsx","./src/modules/components/workflow/nodeerrorboundary.tsx","./src/modules/components/workflow/nodepalette.tsx","./src/modules/components/workflow/nodequickactions.tsx","./src/modules/components/workflow/outputviewdialog.tsx","./src/modules/components/workflow/prompdviewermodal.tsx","./src/modules/components/workflow/propertiespanel.backup.tsx","./src/modules/components/workflow/propertiespanel.tsx","./src/modules/components/workflow/savetemplatedialog.tsx","./src/modules/components/workflow/unifiedworkflowtoolbar.tsx","./src/modules/components/workflow/userinputdialog.tsx","./src/modules/components/workflow/workflowexecutionpanel.tsx","./src/modules/components/workflow/workflowparameterspanel.tsx","./src/modules/components/workflow/workflowrundialog.tsx","./src/modules/components/workflow/workflowsettingsdialog.tsx","./src/modules/components/workflow/workflowtoolbar.tsx","./src/modules/components/workflow/nodecolors.ts","./src/modules/components/workflow/edges/actionedge.tsx","./src/modules/components/workflow/edges/index.ts","./src/modules/components/workflow/hooks/usedocking.ts","./src/modules/components/workflow/nodes/agentnode.tsx","./src/modules/components/workflow/nodes/agentnodeproperties.tsx","./src/modules/components/workflow/nodes/callbacknode.tsx","./src/modules/components/workflow/nodes/callbacknodeproperties.tsx","./src/modules/components/workflow/nodes/chatagentnode.tsx","./src/modules/components/workflow/nodes/chatagentnodeproperties.tsx","./src/modules/components/workflow/nodes/claudecodenode.tsx","./src/modules/components/workflow/nodes/claudecodenodeproperties.tsx","./src/modules/components/workflow/nodes/codenode.tsx","./src/modules/components/workflow/nodes/codenodeproperties.tsx","./src/modules/components/workflow/nodes/commandnode.tsx","./src/modules/components/workflow/nodes/commandnodeproperties.tsx","./src/modules/components/workflow/nodes/conditionnode.tsx","./src/modules/components/workflow/nodes/conditionnodeproperties.tsx","./src/modules/components/workflow/nodes/containernode.tsx","./src/modules/components/workflow/nodes/databasequerynode.tsx","./src/modules/components/workflow/nodes/databasequerynodeproperties.tsx","./src/modules/components/workflow/nodes/dockednodepreview.tsx","./src/modules/components/workflow/nodes/errorhandlernode.tsx","./src/modules/components/workflow/nodes/errorhandlernodeproperties.tsx","./src/modules/components/workflow/nodes/errorhandlerpreviewicon.tsx","./src/modules/components/workflow/nodes/groupnode.tsx","./src/modules/components/workflow/nodes/guardrailnode.tsx","./src/modules/components/workflow/nodes/guardrailnodeproperties.tsx","./src/modules/components/workflow/nodes/loopnode.tsx","./src/modules/components/workflow/nodes/loopnodeproperties.tsx","./src/modules/components/workflow/nodes/mcptoolnode.tsx","./src/modules/components/workflow/nodes/mcptoolnodeproperties.tsx","./src/modules/components/workflow/nodes/memorynode.tsx","./src/modules/components/workflow/nodes/memorynodeproperties.tsx","./src/modules/components/workflow/nodes/mergenode.tsx","./src/modules/components/workflow/nodes/mergenodeproperties.tsx","./src/modules/components/workflow/nodes/nodeexecutionfooter.tsx","./src/modules/components/workflow/nodes/nodeinspectmodal.tsx","./src/modules/components/workflow/nodes/outputnode.tsx","./src/modules/components/workflow/nodes/outputnodeproperties.tsx","./src/modules/components/workflow/nodes/parallelbroadcastnode.tsx","./src/modules/components/workflow/nodes/parallelforknode.tsx","./src/modules/components/workflow/nodes/parallelnode.tsx","./src/modules/components/workflow/nodes/parallelnodeproperties.tsx","./src/modules/components/workflow/nodes/promptnode.tsx","./src/modules/components/workflow/nodes/promptnodeproperties.tsx","./src/modules/components/workflow/nodes/providernode.tsx","./src/modules/components/workflow/nodes/providernodeproperties.tsx","./src/modules/components/workflow/nodes/skillnode.tsx","./src/modules/components/workflow/nodes/skillnodeproperties.tsx","./src/modules/components/workflow/nodes/toolcallparsernode.tsx","./src/modules/components/workflow/nodes/toolcallparsernodeproperties.tsx","./src/modules/components/workflow/nodes/toolcallrouternode.tsx","./src/modules/components/workflow/nodes/toolcallrouternodeproperties.tsx","./src/modules/components/workflow/nodes/toolnode.tsx","./src/modules/components/workflow/nodes/toolnodeproperties.tsx","./src/modules/components/workflow/nodes/toolrouterdockpreview.tsx","./src/modules/components/workflow/nodes/transformnode.tsx","./src/modules/components/workflow/nodes/transformernodeproperties.tsx","./src/modules/components/workflow/nodes/triggernode.tsx","./src/modules/components/workflow/nodes/triggernodeproperties.tsx","./src/modules/components/workflow/nodes/userinputnode.tsx","./src/modules/components/workflow/nodes/userinputnodeproperties.tsx","./src/modules/components/workflow/nodes/websearchnode.tsx","./src/modules/components/workflow/nodes/websearchnodeproperties.tsx","./src/modules/components/workflow/nodes/workflownode.tsx","./src/modules/components/workflow/nodes/workflownodeproperties.tsx","./src/modules/components/workflow/nodes/index.ts","./src/modules/components/workflow/panels/addconnectiondialog.tsx","./src/modules/components/workflow/panels/connectionsettingsdialog.tsx","./src/modules/components/workflow/panels/connectionspanel.tsx","./src/modules/components/workflow/panels/mcpserversetupflow.tsx","./src/modules/components/workflow/panels/triggerconfigdialog.tsx","./src/modules/components/workflow/panels/triggermanagementpanel.tsx","./src/modules/components/workflow/shared/hooks/usenodeconnections.ts","./src/modules/components/workflow/shared/hooks/useprovidernodes.ts","./src/modules/components/workflow/shared/hooks/usesourcesearch.ts","./src/modules/components/workflow/shared/property-components/collapsiblesection.tsx","./src/modules/components/workflow/shared/property-components/connectionselector.tsx","./src/modules/components/workflow/shared/property-components/errorhandlerselector.tsx","./src/modules/components/workflow/shared/property-components/llmproviderconfig.tsx","./src/modules/components/workflow/shared/property-components/sourceselector.tsx","./src/modules/components/workflow/shared/services/filesearchservice.ts","./src/modules/components/workflow/shared/styles/propertystyles.ts","./src/modules/contexts/hotkeycontext.tsx","./src/modules/editor/activitybar.tsx","./src/modules/editor/aichatpanel.tsx","./src/modules/editor/chattab.tsx","./src/modules/editor/compiledpreview.tsx","./src/modules/editor/designview.tsx","./src/modules/editor/editorheader.tsx","./src/modules/editor/executionresultmodal.tsx","./src/modules/editor/fileexplorer.tsx","./src/modules/editor/gitpanel.tsx","./src/modules/editor/installedresourcespanel.tsx","./src/modules/editor/localpackagemodal.tsx","./src/modules/editor/modal.tsx","./src/modules/editor/packagedetailsmodal.tsx","./src/modules/editor/packagepanel.tsx","./src/modules/editor/prompdeditor.tsx","./src/modules/editor/prompdexecutiontab.tsx","./src/modules/editor/spliteditor.tsx","./src/modules/editor/splitviewtoggles.tsx","./src/modules/editor/statusbar.tsx","./src/modules/editor/tabsbar.tsx","./src/modules/editor/workflowcanvas.tsx","./src/modules/hooks/index.ts","./src/modules/hooks/useagentchat.dont-use.ts","./src/modules/hooks/useagentmode.ts","./src/modules/hooks/useinstalledskills.ts","./src/modules/hooks/usemcptools.ts","./src/modules/hooks/usetabmanager.ts","./src/modules/integrations/prompdeditorintegration.ts","./src/modules/lib/editorllmclient.ts","./src/modules/lib/compilevalidation.ts","./src/modules/lib/editorconfig.ts","./src/modules/lib/executionutils.ts","./src/modules/lib/formatters.ts","./src/modules/lib/intellisense.ts","./src/modules/lib/languagedetection.ts","./src/modules/lib/logger.ts","./src/modules/lib/markdowntransform.ts","./src/modules/lib/monacoconfig.ts","./src/modules/lib/monacovariabledecorations.ts","./src/modules/lib/prompdautofix.ts","./src/modules/lib/prompdparser.ts","./src/modules/lib/snippets.ts","./src/modules/lib/storage.ts","./src/modules/lib/templates.ts","./src/modules/lib/textmate.ts","./src/modules/lib/intellisense/codeactions.ts","./src/modules/lib/intellisense/completions.ts","./src/modules/lib/intellisense/context.ts","./src/modules/lib/intellisense/crossreference.ts","./src/modules/lib/intellisense/envcache.ts","./src/modules/lib/intellisense/filters.ts","./src/modules/lib/intellisense/hover.ts","./src/modules/lib/intellisense/index.ts","./src/modules/lib/intellisense/promptpatterns.ts","./src/modules/lib/intellisense/registrysync.ts","./src/modules/lib/intellisense/testcodeactions.ts","./src/modules/lib/intellisense/types.ts","./src/modules/lib/intellisense/utils.ts","./src/modules/lib/intellisense/validation.ts","./src/modules/lib/tiptap/nunjucksextension.ts","./src/modules/services/localllmclient.ts","./src/modules/services/agenttools.ts","./src/modules/services/agentxmlparser.ts","./src/modules/services/aiapi.ts","./src/modules/services/api.ts","./src/modules/services/apiconfig.ts","./src/modules/services/chatmodesapi.ts","./src/modules/services/configservice.ts","./src/modules/services/contextwindowresolver.ts","./src/modules/services/conversationstorage.ts","./src/modules/services/conversationalai.ts","./src/modules/services/diffutils.ts","./src/modules/services/editorservice.ts","./src/modules/services/electronfetch.ts","./src/modules/services/envloader.ts","./src/modules/services/executionrouter.ts","./src/modules/services/executionservice.ts","./src/modules/services/filecontextbuilder.ts","./src/modules/services/hotkeymanager.ts","./src/modules/services/imagestorage.ts","./src/modules/services/llmclientrouter.ts","./src/modules/services/localcompiler.ts","./src/modules/services/localexecutor.ts","./src/modules/services/localprojectstorage.ts","./src/modules/services/memoryservice.ts","./src/modules/services/namespacesapi.ts","./src/modules/services/nodetemplateservice.ts","./src/modules/services/nodetemplatetypes.ts","./src/modules/services/nodetyperegistry.ts","./src/modules/services/onboardingservice.ts","./src/modules/services/packagecache.ts","./src/modules/services/packageservice.ts","./src/modules/services/parameterextraction.ts","./src/modules/services/prompdsettings.ts","./src/modules/services/registryapi.ts","./src/modules/services/registrydiscovery.ts","./src/modules/services/resourcetypes.ts","./src/modules/services/slashcommands.ts","./src/modules/services/slashcommandslocal.ts","./src/modules/services/slashcommandsremote.ts","./src/modules/services/snoweffect.ts","./src/modules/services/startupapi.ts","./src/modules/services/toolexecutor.ts","./src/modules/services/usagetracker.ts","./src/modules/services/workflowexecutor.ts","./src/modules/services/workflowparser.ts","./src/modules/services/workflowtypes.ts","./src/modules/services/workflowvalidator.ts","./src/modules/services/workspaceservice.ts","./src/modules/services/providers/base.ts","./src/modules/services/providers/factory.ts","./src/modules/services/providers/index.ts","./src/modules/services/providers/types.ts","./src/modules/types/hotkeys.ts","./src/modules/types/index.d.ts","./src/modules/types/wizard.ts","./src/modules/wizard/guidedpromptwizard.tsx","./src/modules/wizard/steps/baseselectionstep.tsx","./src/modules/wizard/steps/packagesearchstep.tsx","./src/stores/editorstore.ts","./src/stores/index.ts","./src/stores/types.ts","./src/stores/uistore.ts","./src/stores/wizardstore.ts","./src/stores/workflowstore.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5932bc6..86db400 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -54,7 +54,7 @@ export default defineConfig({ commonjsOptions: { // Default only processes node_modules/. Since @prompd/cli is file-linked // (outside node_modules), we must include it for CJS→ESM conversion. - include: [/node_modules/, /prompd-cli\/cli\/npm\/dist/] + include: [/node_modules/, /prompd-cli[\\/]typescript[\\/]dist/] }, rollupOptions: { external: (id: string) => { diff --git a/packages/react/src/components/PrompdChat.tsx b/packages/react/src/components/PrompdChat.tsx index e34cad7..b97fedc 100644 --- a/packages/react/src/components/PrompdChat.tsx +++ b/packages/react/src/components/PrompdChat.tsx @@ -27,8 +27,10 @@ export const PrompdChat = forwardRef(function onBeforeSubmit, aboveInput, inputTheme = 'default', + generationMode, waitingForUserInput = false, - onStop + onStop, + hideModeSelector = false }, ref) { const { llmClient: defaultLLMClient } = usePrompd() @@ -129,8 +131,11 @@ export const PrompdChat = forwardRef(function onMessage(userMessage) } } else { - // Hidden context - only show thinking indicator - setMessages(prev => [...prev, thinkingMessage]) + // Hidden context (tool results from agent loop) — persist in state + // so subsequent LLM calls include the full conversation history. + // Marked hidden so the UI doesn't render them as chat bubbles. + const hiddenMessage: PrompdChatMessage = { ...userMessage, metadata: { hidden: true } } + setMessages(prev => [...prev, hiddenMessage, thinkingMessage]) } setIsLoading(true) @@ -145,43 +150,210 @@ export const PrompdChat = forwardRef(function }) }) - // Build messages array for LLM + // Build messages array for LLM. + // Both visible and hidden messages are already in state, + // so currentMessages contains the full conversation history + // including the message we just added above. const llmMessages = currentMessages .filter(m => !(m.metadata && 'isThinking' in m.metadata && m.metadata.isThinking)) + .filter(m => m.metadata?.type !== 'thinking-content') .map(m => ({ role: m.role, content: m.content })) - // Add the new message (whether shown in UI or not) - llmMessages.push({ - role: 'user', - content: userMessage.content - }) + // Pre-allocate IDs for streaming messages + const streamingThinkingMsgId = generateMessageId() + const streamingAssistantMsgId = generateMessageId() + let thinkingStarted = false + let contentStarted = false + + // Character-drip streaming buffer. + // HTTP data arrives in large infrequent bursts from the IPC layer, so simple + // frame-rate coalescing still looks choppy. Instead, we queue all incoming text + // and release a fixed number of characters per animation frame, creating a + // smooth, consistent typewriter effect regardless of network burst patterns. + const textQueue = { thinking: '', content: '' } + let dripFrameId: number | null = null + const CHARS_PER_FRAME = 8 // ~480 chars/sec at 60fps — matches typical LLM output speed + + const applyThinking = (text: string) => { + if (!thinkingStarted) { + thinkingStarted = true + setMessages(prev => { + const filtered = prev.filter(m => m.id !== thinkingMessageId) + return [...filtered, { + id: streamingThinkingMsgId, + role: 'assistant' as const, + content: text, + timestamp: new Date().toISOString(), + metadata: { type: 'thinking-content' }, + isStreaming: true + }] + }) + } else { + setMessages(prev => prev.map(m => + m.id === streamingThinkingMsgId + ? { ...m, content: m.content + text } + : m + )) + } + } - const response = await llmClient.send({ - messages: llmMessages - }) + const applyContent = (text: string) => { + if (!contentStarted) { + contentStarted = true + setMessages(prev => { + const filtered = !thinkingStarted + ? prev.filter(m => m.id !== thinkingMessageId) + : prev + return [...filtered, { + id: streamingAssistantMsgId, + role: 'assistant' as const, + content: text, + timestamp: new Date().toISOString(), + isStreaming: true + }] + }) + } else { + setMessages(prev => prev.map(m => + m.id === streamingAssistantMsgId + ? { ...m, content: m.content + text } + : m + )) + } + } - const assistantMessage: PrompdChatMessage = { - id: generateMessageId(), - role: 'assistant', - content: response.content, - timestamp: new Date().toISOString(), - metadata: { - ...response.metadata, // Preserve all metadata from LLM response - provider: response.provider, - model: response.model, - usage: response.usage + const dripLoop = () => { + const bufferSize = textQueue.thinking.length + textQueue.content.length + // Adaptive speed: ramp up linearly as buffer grows to prevent falling behind + const speed = CHARS_PER_FRAME * (1 + Math.floor(bufferSize / 200)) + + if (textQueue.thinking.length > 0) { + const n = Math.min(textQueue.thinking.length, speed) + const released = textQueue.thinking.slice(0, n) + textQueue.thinking = textQueue.thinking.slice(n) + applyThinking(released) + } + + if (textQueue.content.length > 0) { + const n = Math.min(textQueue.content.length, speed) + const released = textQueue.content.slice(0, n) + textQueue.content = textQueue.content.slice(n) + applyContent(released) + } + + // Keep dripping while there's text in the queue + if (textQueue.thinking.length > 0 || textQueue.content.length > 0) { + dripFrameId = requestAnimationFrame(dripLoop) + } else { + dripFrameId = null + } + } + + const onChunk = (chunk: { content?: string; thinking?: string; done: boolean }) => { + if (chunk.done) { + // Flush ALL remaining text immediately on done + if (dripFrameId !== null) { + cancelAnimationFrame(dripFrameId) + dripFrameId = null + } + if (textQueue.thinking) { applyThinking(textQueue.thinking); textQueue.thinking = '' } + if (textQueue.content) { applyContent(textQueue.content); textQueue.content = '' } + return + } + + if (chunk.thinking) textQueue.thinking += chunk.thinking + if (chunk.content) textQueue.content += chunk.content + + // Start the drip loop if not already running + if (dripFrameId === null) { + dripFrameId = requestAnimationFrame(dripLoop) } } - // Remove thinking message and add real response - // If messageAlreadyRendered, the agent hook already added the message before tool execution - if (response.metadata?.messageAlreadyRendered) { - setMessages(prev => prev.filter(m => m.id !== thinkingMessageId)) + const response = await llmClient.send({ + messages: llmMessages, + ...(generationMode ? { mode: generationMode } : {}), + onChunk + }) + + if (contentStarted || thinkingStarted) { + // Streaming was active — finalize messages with metadata + setMessages(prev => { + let result = prev.filter(m => m.id !== thinkingMessageId) + return result.map(m => { + if (m.id === streamingAssistantMsgId) { + return { + ...m, + isStreaming: false, + content: response.content, + metadata: { + ...response.metadata, + provider: response.provider, + model: response.model, + usage: response.usage + } + } + } + if (m.id === streamingThinkingMsgId) { + return { ...m, isStreaming: false } + } + return m + }) + }) + + if (onMessage) { + onMessage({ + id: streamingAssistantMsgId, + role: 'assistant', + content: response.content, + timestamp: new Date().toISOString(), + metadata: { + ...response.metadata, + provider: response.provider, + model: response.model, + usage: response.usage + } + }) + } } else { - setMessages(prev => prev.filter(m => m.id !== thinkingMessageId).concat(assistantMessage)) + // Non-streaming fallback — onChunk was never called + const thinking = response.thinking || (response.metadata?.thinking as string | undefined) + const assistantMessage: PrompdChatMessage = { + id: generateMessageId(), + role: 'assistant', + content: response.content, + timestamp: new Date().toISOString(), + metadata: { + ...response.metadata, + provider: response.provider, + model: response.model, + usage: response.usage, + } + } + + const newMessages: PrompdChatMessage[] = [] + if (thinking) { + newMessages.push({ + id: generateMessageId(), + role: 'assistant', + content: thinking, + timestamp: new Date().toISOString(), + metadata: { type: 'thinking-content' } + }) + } + newMessages.push(assistantMessage) + + if (response.metadata?.messageAlreadyRendered) { + setMessages(prev => prev.filter(m => m.id !== thinkingMessageId)) + } else { + setMessages(prev => prev.filter(m => m.id !== thinkingMessageId).concat(newMessages)) + } + + if (onMessage) { + onMessage(assistantMessage) + } } // If the agent is waiting for user input (ask_user tool call), stop loading to allow input @@ -191,10 +363,6 @@ export const PrompdChat = forwardRef(function setIsLoading(false) isLoadingRef.current = false } - - if (onMessage) { - onMessage(assistantMessage) - } } catch (error) { console.error('Failed to send message:', error) @@ -211,7 +379,7 @@ export const PrompdChat = forwardRef(function setIsLoading(false) isLoadingRef.current = false } - }, [llmClient, onMessage]) + }, [llmClient, onMessage, generationMode]) const handleSubmit = async () => { console.log('[PrompdChat] handleSubmit called - isLoading:', isLoading, 'waitingForUserInput:', waitingForUserInput, 'input:', input.slice(0, 30)) @@ -324,7 +492,7 @@ export const PrompdChat = forwardRef(function history={inputHistory} leftControls={ customLeftControls ?? ( - currentMode && modes && modes.length > 0 && onModeChange ? ( + !hideModeSelector && currentMode && modes && modes.length > 0 && onModeChange ? ( - {messages.map(message => ( - + {messages.filter(m => !m.metadata?.hidden).map(message => ( + message.metadata?.type === 'thinking-content' ? ( + + ) : ( + + ) ))}
) @@ -1038,6 +1050,91 @@ function SlashCommandMessage({ ) } +/** + * Thinking content messages get a distinct visual style — muted colors, + * Thinking content messages are rendered as collapsible blocks (collapsed by default). + */ + +/** + * Collapsible thinking message — shows a compact header that can be expanded + * to reveal the full thinking content. Supports streaming with a spinner. + */ +function CollapsibleThinkingMessage({ + content, + isStreaming, + expanded, + onToggle +}: { + content: string + isStreaming?: boolean + expanded: boolean + onToggle: (expanded: boolean) => void +}) { + return ( +
+
+ {/* Header — always visible, clickable */} + + + {/* Content — only shown when expanded */} + {expanded && ( +
+ +
+ )} +
+
+ ) +} + function Message({ message, onExpandResult, @@ -1060,6 +1157,7 @@ function Message({ const isUser = message.role === 'user' const isSystem = message.role === 'system' const isAssistant = message.role === 'assistant' + const isThinkingContent = isAssistant && message.metadata?.type === 'thinking-content' return (
- {/* Avatar - Beautiful Gradient Orbs */} -
+ {/* Avatar - Beautiful Gradient Orbs (self-start pins to top of flex row) */} +
@@ -1087,6 +1189,8 @@ function Message({ ) : isSystem ? ( + ) : isThinkingContent ? ( + ) : ( )} @@ -1114,19 +1218,24 @@ function Message({ > {/* Message Bubble - Sleek & Modern */}
{/* System Message: Package Suggestions */} @@ -1360,10 +1469,31 @@ function Message({ {/* Regular message content (not system special types) */} {(!isSystem || !message.metadata?.type) ? ( - +
+ +
+ ) : null} + + {/* Thinking Content Message — rendered as its own message type */} + {isAssistant && message.metadata?.type === 'thinking-content' ? ( +
+ + + + + Thinking +
) : null} {/* Run Info Footer - Inline within message bubble */} diff --git a/packages/react/src/components/PrompdModeDropdown.tsx b/packages/react/src/components/PrompdModeDropdown.tsx index a8488f0..7547b85 100644 --- a/packages/react/src/components/PrompdModeDropdown.tsx +++ b/packages/react/src/components/PrompdModeDropdown.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { clsx } from 'clsx' -import { Sparkles, Pencil, Search, MessageCircle, Check, type LucideIcon } from 'lucide-react' +import { Sparkles, Pencil, Search, MessageCircle, Lightbulb, Check, type LucideIcon } from 'lucide-react' import type { PrompdChatMode } from '../types' // Map icon names to Lucide components @@ -8,7 +8,8 @@ const iconMap: Record = { Sparkles, Pencil, Search, - MessageCircle + MessageCircle, + Lightbulb } function ModeIcon({ name, size = 18 }: { name: string; size?: number }) { diff --git a/packages/react/src/components/PrompdParameterList.tsx b/packages/react/src/components/PrompdParameterList.tsx index bafda2a..97bf159 100644 --- a/packages/react/src/components/PrompdParameterList.tsx +++ b/packages/react/src/components/PrompdParameterList.tsx @@ -928,7 +928,7 @@ function ExpandableParameterCard({ {/* Metadata Row */}
{param.default !== undefined && ( - Default: {String(param.default)} + Default: {typeof param.default === 'object' ? JSON.stringify(param.default) : String(param.default)} )} {param.required && ( Required diff --git a/packages/react/src/components/parameters/index.ts b/packages/react/src/components/parameters/index.ts index 4f7c189..ae8f89d 100644 --- a/packages/react/src/components/parameters/index.ts +++ b/packages/react/src/components/parameters/index.ts @@ -17,6 +17,11 @@ export { TextInput, ObjectInput, ArrayPillInput, + FileInput, + type FileValue, + JsonInput, + Base64Input, + JwtInput, } from './inputs' // Card components @@ -39,6 +44,10 @@ export { isEnumType, isNumericType, isBooleanType, + isFileType, + isJsonType, + isBase64Type, + isJwtType, getInputType, parseNumericValue, isEmptyValue, diff --git a/packages/react/src/components/parameters/inputs/ArrayPillInput.tsx b/packages/react/src/components/parameters/inputs/ArrayPillInput.tsx index ff22c69..8025e3e 100644 --- a/packages/react/src/components/parameters/inputs/ArrayPillInput.tsx +++ b/packages/react/src/components/parameters/inputs/ArrayPillInput.tsx @@ -164,7 +164,7 @@ export function ArrayPillInput({ 'animate-fade-in' )} > - {item} + {typeof item === 'object' && item !== null ? JSON.stringify(item) : item} {!disabled && ( + + + )} +
+
+ + {/* Raw base64 textarea (toggled) */} + {showRaw && ( +