diff --git a/src/generator.ts b/src/generator.ts index 45b3d9c..7247b26 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -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" + // ============================================= - new Setting(contentEl) - .setName('Language') - .addDropdown(dropdown => dropdown - .addOption('english', 'English') - .addOption('german', 'Deutsch') - .setValue(this.config.language) - .onChange(v => this.config.language = v)); + // === FILES TO GENERATE === + selectionZone.createEl('h3', { text: 'Files to generate' }); - // === FORMATTING SECTION === - contentEl.createEl('h3', { text: 'Formatting' }); + new Setting(selectionZone) + .setName('conventions.md') + .addToggle(toggle => toggle + .setValue(this.config.generateFiles.conventions) + .onChange(v => this.config.generateFiles.conventions = v)); - 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(selectionZone) + .setName('structure.md') + .addToggle(toggle => toggle + .setValue(this.config.generateFiles.structure) + .onChange(v => this.config.generateFiles.structure = 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(selectionZone) + .setName('workflows.md') + .addToggle(toggle => toggle + .setValue(this.config.generateFiles.workflows) + .onChange(v => this.config.generateFiles.workflows = 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(selectionZone) + .setName('templates.md') + .addToggle(toggle => toggle + .setValue(this.config.generateFiles.templates) + .onChange(v => this.config.generateFiles.templates = 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)); + 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' + }); + + 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[] = []; diff --git a/styles.css b/styles.css index 0d19e2a..1528168 100644 --- a/styles.css +++ b/styles.css @@ -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; + } +}