feat: restructure generator modal into two-zone layout

Split the single-column generator modal into a Selection zone (what to
include) and Configuration zone (how to output) using CSS grid, with a
full-width footer for action buttons. Adds note count indicator and
token estimate from existing context files. Responsive: side-by-side
on wide viewports, stacked on narrow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca G. Oelfke 2026-02-06 11:24:51 +01:00
parent a6cc173293
commit 08b8a180ca
No known key found for this signature in database
GPG key ID: E22BABF67200F864
2 changed files with 261 additions and 149 deletions

View file

@ -1,6 +1,7 @@
import { App, Modal, Notice, Setting, TFolder } from 'obsidian';
import { App, Modal, Notice, Setting, TFile, TFolder } from 'obsidian';
import ClaudeContextPlugin from './main';
import { createFreetextSource, SourcePosition } from './sources';
import { estimateTokens } from './history';
import { OutputTarget, getTargetIcon, formatTokenCount } from './targets';
interface FolderConfig {
@ -103,77 +104,66 @@ export class ContextGeneratorModal extends Modal {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('claude-context-generator');
contentEl.style.maxHeight = '80vh';
contentEl.style.overflow = 'auto';
this.modalEl.addClass('cc-gen-modal');
contentEl.createEl('h2', { text: 'Context Generator' });
// === BASIC SECTION ===
contentEl.createEl('h3', { text: 'General' });
// === TWO-ZONE GRID LAYOUT ===
const layout = contentEl.createDiv({ cls: 'cc-gen-layout' });
const selectionZone = layout.createDiv({ cls: 'cc-gen-zone cc-gen-selection' });
const configZone = layout.createDiv({ cls: 'cc-gen-zone cc-gen-configuration' });
new Setting(contentEl)
.setName('Vault description')
.setDesc('What is this vault used for?')
.addTextArea(text => {
text.setPlaceholder('e.g. Personal Zettelkasten for development and knowledge management')
.setValue(this.config.vaultDescription)
.onChange(v => this.config.vaultDescription = v);
text.inputEl.rows = 2;
text.inputEl.style.width = '100%';
// =============================================
// SELECTION ZONE (left) "What to include"
// =============================================
// === FILES TO GENERATE ===
selectionZone.createEl('h3', { text: 'Files to generate' });
new Setting(selectionZone)
.setName('conventions.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.conventions)
.onChange(v => this.config.generateFiles.conventions = v));
new Setting(selectionZone)
.setName('structure.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.structure)
.onChange(v => this.config.generateFiles.structure = v));
new Setting(selectionZone)
.setName('workflows.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.workflows)
.onChange(v => this.config.generateFiles.workflows = v));
new Setting(selectionZone)
.setName('templates.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.templates)
.onChange(v => this.config.generateFiles.templates = v));
new Setting(selectionZone)
.setName('examples.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.examples)
.onChange(v => this.config.generateFiles.examples = v));
// === FOLDER STRUCTURE ===
selectionZone.createEl('h3', { text: 'Folder structure' });
selectionZone.createEl('p', {
text: 'Describe the purpose of your folders:',
cls: 'setting-item-description'
});
new Setting(contentEl)
.setName('Language')
.addDropdown(dropdown => dropdown
.addOption('english', 'English')
.addOption('german', 'Deutsch')
.setValue(this.config.language)
.onChange(v => this.config.language = v));
// === FORMATTING SECTION ===
contentEl.createEl('h3', { text: 'Formatting' });
new Setting(contentEl)
.setName('File naming')
.addDropdown(dropdown => dropdown
.addOption('kebab-case', 'kebab-case')
.addOption('snake_case', 'snake_case')
.addOption('camelCase', 'camelCase')
.addOption('free', 'Free / no convention')
.setValue(this.config.fileNaming)
.onChange(v => this.config.fileNaming = v));
new Setting(contentEl)
.setName('Link style')
.addDropdown(dropdown => dropdown
.addOption('wikilinks', '[[Wikilinks]]')
.addOption('markdown', '[Markdown](links)')
.setValue(this.config.linkStyle)
.onChange(v => this.config.linkStyle = v));
new Setting(contentEl)
.setName('Heading depth')
.addDropdown(dropdown => dropdown
.addOption('h2', 'H1 - H2')
.addOption('h3', 'H1 - H3')
.addOption('h4', 'H1 - H4')
.addOption('h6', 'Unlimited')
.setValue(this.config.headingDepth)
.onChange(v => this.config.headingDepth = v));
new Setting(contentEl)
.setName('Date format')
.addDropdown(dropdown => dropdown
.addOption('YYYY-MM-DD', 'YYYY-MM-DD (ISO)')
.addOption('DD.MM.YYYY', 'DD.MM.YYYY')
.addOption('MM/DD/YYYY', 'MM/DD/YYYY')
.setValue(this.config.dateFormat)
.onChange(v => this.config.dateFormat = v));
const foldersContainer = selectionZone.createDiv({ cls: 'folders-container' });
this.renderFolders(foldersContainer);
// === TAGS SECTION ===
contentEl.createEl('h3', { text: 'Tags' });
selectionZone.createEl('h3', { text: 'Tags' });
new Setting(contentEl)
new Setting(selectionZone)
.setName('Tag style')
.addDropdown(dropdown => dropdown
.addOption('hierarchical', 'Hierarchical (#area/tag)')
@ -182,7 +172,7 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.config.tagsStyle)
.onChange(v => this.config.tagsStyle = v));
new Setting(contentEl)
new Setting(selectionZone)
.setName('Predefined tags')
.setDesc('Comma-separated (e.g. status/active, status/done, project)')
.addText(text => text
@ -193,9 +183,9 @@ export class ContextGeneratorModal extends Modal {
}));
// === FRONTMATTER SECTION ===
contentEl.createEl('h3', { text: 'Frontmatter' });
selectionZone.createEl('h3', { text: 'Frontmatter' });
new Setting(contentEl)
new Setting(selectionZone)
.setName('Frontmatter fields')
.setDesc('Comma-separated (e.g. date, tags, aliases, status)')
.addText(text => text
@ -204,10 +194,98 @@ export class ContextGeneratorModal extends Modal {
this.config.frontmatterFields = v.split(',').map(s => s.trim()).filter(s => s);
}));
// === RULES SECTION ===
contentEl.createEl('h3', { text: 'Rules' });
// === NOTE TEMPLATES ===
selectionZone.createEl('h3', { text: 'Note templates' });
new Setting(contentEl)
const templatesContainer = selectionZone.createDiv({ cls: 'templates-container' });
this.renderTemplates(templatesContainer);
new Setting(selectionZone)
.addButton(btn => btn
.setButtonText('+ Add template')
.onClick(() => {
this.config.templates.push({ name: '', folder: '', tag: '' });
this.renderTemplates(templatesContainer);
}));
// === NOTE COUNT INDICATOR ===
const fileCount = this.app.vault.getFiles().length;
const folderCount = this.config.folders.length;
selectionZone.createDiv({
cls: 'cc-gen-stat',
text: `${folderCount} folders, ${fileCount} total files in vault`,
});
// =============================================
// CONFIGURATION ZONE (right) "How to output"
// =============================================
// === GENERAL ===
configZone.createEl('h3', { text: 'General' });
new Setting(configZone)
.setName('Vault description')
.setDesc('What is this vault used for?')
.addTextArea(text => {
text.setPlaceholder('e.g. Personal Zettelkasten for development and knowledge management')
.setValue(this.config.vaultDescription)
.onChange(v => this.config.vaultDescription = v);
text.inputEl.rows = 2;
text.inputEl.style.width = '100%';
});
new Setting(configZone)
.setName('Language')
.addDropdown(dropdown => dropdown
.addOption('english', 'English')
.addOption('german', 'Deutsch')
.setValue(this.config.language)
.onChange(v => this.config.language = v));
// === FORMATTING ===
configZone.createEl('h3', { text: 'Formatting' });
new Setting(configZone)
.setName('File naming')
.addDropdown(dropdown => dropdown
.addOption('kebab-case', 'kebab-case')
.addOption('snake_case', 'snake_case')
.addOption('camelCase', 'camelCase')
.addOption('free', 'Free / no convention')
.setValue(this.config.fileNaming)
.onChange(v => this.config.fileNaming = v));
new Setting(configZone)
.setName('Link style')
.addDropdown(dropdown => dropdown
.addOption('wikilinks', '[[Wikilinks]]')
.addOption('markdown', '[Markdown](links)')
.setValue(this.config.linkStyle)
.onChange(v => this.config.linkStyle = v));
new Setting(configZone)
.setName('Heading depth')
.addDropdown(dropdown => dropdown
.addOption('h2', 'H1 - H2')
.addOption('h3', 'H1 - H3')
.addOption('h4', 'H1 - H4')
.addOption('h6', 'Unlimited')
.setValue(this.config.headingDepth)
.onChange(v => this.config.headingDepth = v));
new Setting(configZone)
.setName('Date format')
.addDropdown(dropdown => dropdown
.addOption('YYYY-MM-DD', 'YYYY-MM-DD (ISO)')
.addOption('DD.MM.YYYY', 'DD.MM.YYYY')
.addOption('MM/DD/YYYY', 'MM/DD/YYYY')
.setValue(this.config.dateFormat)
.onChange(v => this.config.dateFormat = v));
// === RULES ===
configZone.createEl('h3', { text: 'Rules' });
new Setting(configZone)
.setName('Custom rules')
.setDesc('One rule per line')
.addTextArea(text => {
@ -220,7 +298,7 @@ export class ContextGeneratorModal extends Modal {
text.inputEl.style.width = '100%';
});
new Setting(contentEl)
new Setting(configZone)
.setName('Forbidden actions')
.setDesc('Comma-separated (e.g. .obsidian/, certain folders)')
.addText(text => text
@ -229,67 +307,10 @@ export class ContextGeneratorModal extends Modal {
this.config.forbiddenActions = v.split(',').map(s => s.trim()).filter(s => s);
}));
// === STRUCTURE SECTION ===
contentEl.createEl('h3', { text: 'Folder structure' });
contentEl.createEl('p', {
text: 'Describe the purpose of your folders:',
cls: 'setting-item-description'
});
// === ADDITIONAL CONTEXT ===
configZone.createEl('h3', { text: 'Additional Context (this session)' });
const foldersContainer = contentEl.createDiv({ cls: 'folders-container' });
this.renderFolders(foldersContainer);
// === TEMPLATES SECTION ===
contentEl.createEl('h3', { text: 'Note templates' });
const templatesContainer = contentEl.createDiv({ cls: 'templates-container' });
this.renderTemplates(templatesContainer);
new Setting(contentEl)
.addButton(btn => btn
.setButtonText('+ Add template')
.onClick(() => {
this.config.templates.push({ name: '', folder: '', tag: '' });
this.renderTemplates(templatesContainer);
}));
// === FILES TO GENERATE ===
contentEl.createEl('h3', { text: 'Files to generate' });
new Setting(contentEl)
.setName('conventions.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.conventions)
.onChange(v => this.config.generateFiles.conventions = v));
new Setting(contentEl)
.setName('structure.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.structure)
.onChange(v => this.config.generateFiles.structure = v));
new Setting(contentEl)
.setName('workflows.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.workflows)
.onChange(v => this.config.generateFiles.workflows = v));
new Setting(contentEl)
.setName('templates.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.templates)
.onChange(v => this.config.generateFiles.templates = v));
new Setting(contentEl)
.setName('examples.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.examples)
.onChange(v => this.config.generateFiles.examples = v));
// === ADDITIONAL CONTEXT SECTION ===
contentEl.createEl('h3', { text: 'Additional Context (this session)' });
new Setting(contentEl)
new Setting(configZone)
.setName('Temporary freetext')
.setDesc('Add context for this session only')
.addTextArea(text => {
@ -300,7 +321,7 @@ export class ContextGeneratorModal extends Modal {
text.inputEl.style.width = '100%';
});
new Setting(contentEl)
new Setting(configZone)
.setName('Position')
.addDropdown(dropdown => dropdown
.addOption('prefix', 'Prefix (before vault content)')
@ -308,25 +329,24 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.temporaryPosition)
.onChange(v => this.temporaryPosition = v as SourcePosition));
new Setting(contentEl)
new Setting(configZone)
.setName('Save as default')
.setDesc('Save this freetext as a permanent source')
.addToggle(toggle => toggle
.setValue(this.saveAsDefault)
.onChange(v => this.saveAsDefault = v));
// Show saved sources count
const enabledCount = this.plugin.settings.sources.filter(s => s.enabled).length;
const sourcesInfo = contentEl.createEl('p', {
const sourcesInfo = configZone.createEl('p', {
text: `Saved sources: ${enabledCount} enabled (manage in settings)`,
cls: 'setting-item-description'
});
sourcesInfo.style.marginTop = '10px';
// === PROMPT TEMPLATE SECTION ===
contentEl.createEl('h3', { text: 'Prompt Template' });
// === PROMPT TEMPLATE ===
configZone.createEl('h3', { text: 'Prompt Template' });
new Setting(contentEl)
new Setting(configZone)
.setName('Template')
.setDesc('Wrap context with a prompt template')
.addDropdown(dropdown => {
@ -340,19 +360,18 @@ export class ContextGeneratorModal extends Modal {
});
});
// Show template count
const templateCount = this.plugin.settings.promptTemplates.length;
const templateInfo = contentEl.createEl('p', {
const templateInfo = configZone.createEl('p', {
text: `${templateCount} template(s) available (manage in settings)`,
cls: 'setting-item-description'
});
templateInfo.style.marginTop = '5px';
// === OUTPUT TARGETS SECTION ===
// === OUTPUT TARGETS ===
if (this.plugin.settings.targets.length > 0) {
contentEl.createEl('h3', { text: 'Output Targets' });
configZone.createEl('h3', { text: 'Output Targets' });
const targetsContainer = contentEl.createDiv({ cls: 'targets-checkboxes' });
const targetsContainer = configZone.createDiv({ cls: 'targets-checkboxes' });
targetsContainer.style.display = 'flex';
targetsContainer.style.flexDirection = 'column';
targetsContainer.style.gap = '8px';
@ -385,7 +404,6 @@ export class ContextGeneratorModal extends Modal {
const label = row.createEl('span', { text: target.name });
label.style.flex = '1';
// Primary indicator
if (target.id === primaryId) {
const badge = row.createEl('span', { text: 'clipboard' });
badge.style.padding = '2px 6px';
@ -408,24 +426,29 @@ export class ContextGeneratorModal extends Modal {
tokenInfo.style.color = 'var(--text-muted)';
}
const targetsInfo = contentEl.createEl('p', {
const targetsInfo = configZone.createEl('p', {
text: 'Primary target is copied to clipboard. Secondary targets are saved as files in the output folder.',
cls: 'setting-item-description'
});
targetsInfo.style.marginTop = '5px';
}
// Copy buttons
const copyButtonContainer = contentEl.createDiv();
// === TOKEN ESTIMATE ===
this.renderTokenEstimate(configZone);
// =============================================
// FOOTER (full-width, below both zones)
// =============================================
const footer = contentEl.createDiv({ cls: 'cc-gen-footer' });
const copyButtonContainer = footer.createDiv();
copyButtonContainer.style.display = 'flex';
copyButtonContainer.style.gap = '10px';
copyButtonContainer.style.marginTop = '10px';
new Setting(copyButtonContainer)
.addButton(btn => btn
.setButtonText('Copy Context Now')
.onClick(async () => {
// Save freetext if requested
if (this.saveAsDefault && this.temporaryFreetext.trim()) {
const source = createFreetextSource(
'Generator Context',
@ -437,12 +460,10 @@ export class ContextGeneratorModal extends Modal {
new Notice('Freetext saved as default source');
}
// Get selected targets
const selectedTargets = this.plugin.settings.targets.filter(
t => this.selectedTargetIds.has(t.id)
);
// Copy context with selected template and targets
await this.plugin.copyContextToClipboard(
false,
this.temporaryFreetext,
@ -458,10 +479,9 @@ export class ContextGeneratorModal extends Modal {
this.plugin.openFileSelector();
}));
// === GENERATE BUTTON ===
contentEl.createEl('hr');
footer.createEl('hr');
new Setting(contentEl)
new Setting(footer)
.addButton(btn => btn
.setButtonText('Generate')
.setCta()
@ -532,6 +552,27 @@ export class ContextGeneratorModal extends Modal {
});
}
async renderTokenEstimate(container: HTMLElement) {
const contextFolder = this.plugin.settings.contextFolder;
const folder = this.app.vault.getAbstractFileByPath(contextFolder);
let totalChars = 0;
if (folder instanceof TFolder) {
for (const child of folder.children) {
if (child instanceof TFile && child.extension === 'md') {
const content = await this.app.vault.cachedRead(child);
totalChars += content.length;
}
}
}
const tokens = Math.ceil(totalChars / 4);
container.createDiv({
cls: 'cc-gen-stat',
text: `Estimated tokens: ~${tokens.toLocaleString()} (from existing context files)`,
});
}
scanVaultStructure(): string[] {
const root = this.app.vault.getRoot();
const folders: string[] = [];

View file

@ -79,3 +79,74 @@
opacity: 0;
padding-top: 0;
}
/* Generator modal */
.cc-gen-modal {
width: 70vw;
max-width: 900px;
}
.cc-gen-modal .modal-content {
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.cc-gen-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
flex: 1;
min-height: 0;
}
.cc-gen-zone {
overflow-y: auto;
max-height: 60vh;
padding-right: 8px;
}
.cc-gen-selection {
border-right: 1px solid var(--background-modifier-border);
padding-right: 20px;
}
.cc-gen-zone h3 {
margin-top: 0;
}
.cc-gen-footer {
border-top: 1px solid var(--background-modifier-border);
padding-top: 12px;
margin-top: 12px;
}
.cc-gen-stat {
font-size: 12px;
color: var(--text-muted);
padding: 6px 10px;
background: var(--background-secondary);
border-radius: 4px;
margin-top: 8px;
}
/* Stack on narrow viewports */
@media (max-width: 768px) {
.cc-gen-modal {
width: 90vw;
}
.cc-gen-layout {
grid-template-columns: 1fr;
}
.cc-gen-selection {
border-right: none;
border-bottom: 1px solid var(--background-modifier-border);
padding-right: 0;
padding-bottom: 16px;
max-height: none;
}
.cc-gen-zone {
max-height: none;
}
}