diff --git a/README.md b/README.md
index ddcc554..ecd76e5 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,14 @@ node identifier and sockets. The reusable architecture is inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
+**Prompt Manager (Focused)** adds an editor-first layout with collapsible
+composition and metadata fields. Open **Browse library** to search saved prompts,
+preview a selection, then choose **Use prompt**; **Load metadata** controls whether
+category and tags come with it. Prepend and append STRING sockets remain visible
+when the fields are collapsed, so LoRA trigger outputs can connect directly.
+Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
+support ComfyUI Classic and Nodes 2.0.
+
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
diff --git a/__init__.py b/__init__.py
index 6593b25..f4e3076 100644
--- a/__init__.py
+++ b/__init__.py
@@ -3,12 +3,13 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
with persistent prompt storage, advanced search capabilities, and an automatic image
gallery system.
-This module provides two main node types:
+This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
+- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
-Both nodes share the same database backend for persistent prompt storage and include
+The nodes share the same database backend for persistent prompt storage and include
an automatic image gallery system that monitors ComfyUI output directories and links
generated images to their source prompts.
@@ -38,18 +39,20 @@ def get_version():
return "unknown"
-from .prompt_manager import PromptManager # noqa: E402
+from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
+ "PromptManagerFocused": PromptManagerFocused,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
+ "PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-dark.png
new file mode 100644
index 0000000..1cf487b
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-light.png
new file mode 100644
index 0000000..0219fe5
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-light.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png
new file mode 100644
index 0000000..f241258
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png
new file mode 100644
index 0000000..e4ccc0d
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png differ
diff --git a/prompt_manager.py b/prompt_manager.py
index 90904c0..a0d9fce 100644
--- a/prompt_manager.py
+++ b/prompt_manager.py
@@ -281,3 +281,18 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
return hashlib.sha256(combined.encode()).hexdigest()
+
+
+class PromptManagerFocused(PromptManager):
+ """The editor-first UI uses the existing encoding and storage contract."""
+
+ @classmethod
+ def INPUT_TYPES(cls) -> InputTypeDict:
+ inputs = super().INPUT_TYPES()
+ for name in ("prepend_text", "append_text"):
+ kind, options = inputs["optional"][name]
+ inputs["optional"][name] = (
+ kind,
+ {**options, "default": "", "forceInput": True},
+ )
+ return inputs
diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs
new file mode 100644
index 0000000..e43476c
--- /dev/null
+++ b/tests/browser/focused-node.spec.mjs
@@ -0,0 +1,244 @@
+import {readFileSync} from 'node:fs';
+import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
+
+test.beforeEach(async({page,browserName})=>{
+ await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
+ await startCoverage(page,browserName);
+ page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message));
+ await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
+ await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
+ await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
+ await page.goto('/prompt_manager/test');
+ await page.evaluate(async()=>{
+ document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
+ window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
+ await import('/extensions/promptmanager/prompt_manager.js');
+ class Node {
+ constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
+ addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
+ addDOMWidget(name,type,element){document.body.append(element);return {element};}
+ setSize(size){this.size=size;}setDirtyCanvas(){}
+ serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
+ configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
+ }
+ await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
+ window.node=new Node();node.onNodeCreated();
+ });
+ await expect(page.locator('.pm-focused')).toBeVisible();
+});
+test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
+
+test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
+ await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
+ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ await page.getByRole('button',{name:'Recent',exact:true}).click();
+ await page.getByRole('button',{name:/Library portrait/}).click();
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
+ await page.getByRole('switch',{name:'Load metadata'}).uncheck();
+ await page.getByRole('button',{name:'Use prompt',exact:true}).click();
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
+ await page.getByText('Composition · prepend / append',{exact:true}).click();
+ await page.getByLabel('Prepend',{exact:true}).fill('trigger');
+ await page.getByLabel('Append',{exact:true}).fill('detailed');
+ await page.getByText('Composed text preview',{exact:true}).click();
+ await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
+ expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
+});
+
+test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
+ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ await page.getByLabel('Category',{exact:true}).fill('Saved category');
+ await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
+ await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
+ await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
+ await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
+ await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
+ await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
+});
+
+test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
+ let requested;
+ await page.route('**/prompt_manager/search?**', async route => {
+ requested = new URL(route.request().url());
+ await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
+ });
+ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ const query=page.getByRole('searchbox',{name:'Search saved prompts'});
+ await query.fill('fox tag:"film grain"');
+ await query.press('Enter');
+ await page.getByRole('button',{name:/Selected fox/}).click();
+ expect(requested.searchParams.get('text')).toBe('fox');
+ expect(requested.searchParams.get('tags')).toBe('film grain');
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
+ await page.getByRole('button',{name:'Use prompt',exact:true}).click();
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
+ await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
+ expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
+});
+
+test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
+ let requests=0;
+ let mode='empty';
+ const malicious='
';
+ await page.route('**/prompt_manager/search?**', async route => {
+ requests++;
+ if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
+ await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
+ });
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ const search=page.getByRole('button',{name:'Search',exact:true});
+ await search.click();
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text or tag:name.');
+ expect(requests).toBe(0);
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
+ await search.click();
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
+ await expect(page.locator('.pm-editor-result')).toHaveCount(0);
+ mode='error';
+ await search.click();
+ await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
+ await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
+ expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
+ mode='http';
+ await search.click();
+ await expect(page.getByRole('alert')).toContainText('HTTP 503');
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
+ await search.click();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text or tag:name.');
+ mode='empty';
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
+ await search.click();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
+});
+
+test('Focused treats saved prompt content and metadata as text', async ({page}) => {
+ const malicious='
';
+ await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'',tags:['