diff --git a/README.md b/README.md
index 8387d8d..1e1db44 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,12 @@ 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.
+**Prompt Manager (Split)** keeps the same editor and library visible together.
+Wide nodes place the library beside the editor; narrow nodes stack the panels.
+It shares Focused's category/tag autocomplete, search filters, preview-before-use,
+workflow persistence, and connectable prepend/append inputs. Encoding and storage
+match Classic and Focused.
+
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
diff --git a/__init__.py b/__init__.py
index f4e3076..f05e973 100644
--- a/__init__.py
+++ b/__init__.py
@@ -6,6 +6,7 @@ gallery system.
This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
- PromptManagerFocused: Editor-first layout using the same encoding contract
+- PromptManagerSplit: Side-by-side editor and library using the same contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
@@ -39,13 +40,18 @@ def get_version():
return "unknown"
-from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
+from .prompt_manager import ( # noqa: E402
+ PromptManager,
+ PromptManagerFocused,
+ PromptManagerSplit,
+)
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
+ "PromptManagerSplit": PromptManagerSplit,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
@@ -53,6 +59,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerFocused": "Prompt Manager (Focused)",
+ "PromptManagerSplit": "Prompt Manager (Split)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
diff --git a/docs/screenshots/split-toolkit/classic-start-split-dark.png b/docs/screenshots/split-toolkit/classic-start-split-dark.png
new file mode 100644
index 0000000..f590c8a
Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-dark.png differ
diff --git a/docs/screenshots/split-toolkit/classic-start-split-default-selected.png b/docs/screenshots/split-toolkit/classic-start-split-default-selected.png
new file mode 100644
index 0000000..0fa89e5
Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-default-selected.png differ
diff --git a/docs/screenshots/split-toolkit/classic-start-split-light.png b/docs/screenshots/split-toolkit/classic-start-split-light.png
new file mode 100644
index 0000000..a362387
Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-light.png differ
diff --git a/docs/screenshots/split-toolkit/classic-start-split-narrow.png b/docs/screenshots/split-toolkit/classic-start-split-narrow.png
new file mode 100644
index 0000000..e1ab08e
Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-narrow.png differ
diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-dark.png b/docs/screenshots/split-toolkit/nodes2-start-split-dark.png
new file mode 100644
index 0000000..2081f18
Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-dark.png differ
diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-default-selected.png b/docs/screenshots/split-toolkit/nodes2-start-split-default-selected.png
new file mode 100644
index 0000000..b678959
Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-default-selected.png differ
diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-light.png b/docs/screenshots/split-toolkit/nodes2-start-split-light.png
new file mode 100644
index 0000000..183d5c3
Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-light.png differ
diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-narrow.png b/docs/screenshots/split-toolkit/nodes2-start-split-narrow.png
new file mode 100644
index 0000000..3e1fb09
Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-narrow.png differ
diff --git a/prompt_manager.py b/prompt_manager.py
index a0d9fce..b070e02 100644
--- a/prompt_manager.py
+++ b/prompt_manager.py
@@ -296,3 +296,7 @@ class PromptManagerFocused(PromptManager):
{**options, "default": "", "forceInput": True},
)
return inputs
+
+
+class PromptManagerSplit(PromptManagerFocused):
+ """The persistent library layout shares Focused encoding and storage."""
diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs
index cf78a3c..acc6fe0 100644
--- a/tests/browser/focused-node.spec.mjs
+++ b/tests/browser/focused-node.spec.mjs
@@ -2,6 +2,17 @@ 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';
+for (const variant of ['Focused', 'Split']) {
+test.describe(variant, () => {
+const isSplit = variant === 'Split';
+const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
+async function openLibrary(page) {
+ if (isSplit) {
+ await expect(page.locator('.pm-editor-library')).toBeVisible();
+ await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
+ } else await page.getByRole('button',{name:'Browse library',exact:true}).click();
+}
+
test.beforeEach(async({page,browserName})=>{
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
await startCoverage(page,browserName);
@@ -10,26 +21,26 @@ test.beforeEach(async({page,browserName})=>{
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()=>{
+ await page.evaluate(async variant=>{
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};}
+ addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);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);
+ await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
window.node=new Node();node.onNodeCreated();
- });
+ },variant);
await expect(page.locator('.pm-focused')).toBeVisible();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
-test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
+test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
@@ -38,7 +49,7 @@ test('Focused library autocompletes filters and searches without changing editor
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
});
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
- await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ await openLibrary(page);
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
@@ -60,7 +71,7 @@ test('Focused library autocompletes filters and searches without changing editor
await category.fill('Landscape');
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
- expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
+ expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
@@ -69,14 +80,15 @@ test('Focused library autocompletes filters and searches without changing editor
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
expect(recentRequest.searchParams.has('category')).toBe(false);
expect(recentRequest.searchParams.has('tags')).toBe(false);
- await page.getByRole('button',{name:'Hide library',exact:true}).click();
+ if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
+ else await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
-test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
+test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
- await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ await openLibrary(page);
await page.getByLabel('Filter category',{exact:true}).fill('New category');
await page.getByLabel('Filter category',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(1);
@@ -88,7 +100,7 @@ test('Focused library can search by filters alone and Enter searches after autoc
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
-test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
+test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
await page.route('**/prompt_manager/tags/suggest?**', route => {
const q = new URL(route.request().url()).searchParams.get('q');
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
@@ -118,7 +130,7 @@ test('Focused completes saved tags with keyboard and pointer and appends commas'
await expect(tags).toHaveValue('A brand new tag');
});
-test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
+test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
let release;
let mode='delayed';
await page.route('**/prompt_manager/tags/suggest?**', async route => {
@@ -146,7 +158,7 @@ test('Focused metadata autocomplete ignores stale responses, recovers from error
await expect(page.getByRole('listbox')).toHaveCount(0);
});
-test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
+test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
await page.getByText('Metadata · category / tags',{exact:true}).click();
const category=page.getByLabel('Category',{exact:true});
@@ -168,7 +180,7 @@ test('Focused completes one category, allows new values and dismisses suggestion
await expect(category).toHaveValue('New category');
});
-test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
+test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
await page.evaluate(async()=>{
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
@@ -196,10 +208,10 @@ test('Focused shares suggestion controls safely with another bundled toolkit cop
await expect(lists).toHaveCount(0);
});
-test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
+test(`${variant} 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 openLibrary(page);
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');
@@ -214,7 +226,7 @@ test('Focused previews before loading, keeps metadata optional and composes text
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})=>{
+test(`${variant} 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');
@@ -228,14 +240,14 @@ test('Focused restores workflow values and disclosures and protects connected te
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}) => {
+test(`${variant} 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();
+ await openLibrary(page);
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
await query.fill('fox tag:"film grain"');
await query.press('Enter');
@@ -250,7 +262,7 @@ test('Focused searches quoted tags with Enter and explicitly loads metadata', as
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}) => {
+test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
let requests=0;
let mode='empty';
const malicious='
';
@@ -259,7 +271,7 @@ test('Focused reports empty queries, no results, and safely renders backend and
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();
+ await openLibrary(page);
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, a category or tags.');
@@ -287,10 +299,10 @@ test('Focused reports empty queries, no results, and safely renders backend and
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}) => {
+test(`${variant} 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:['