feat: add live preview pane and status bar to generator modal

Collapsible preview shows first 30 lines of generated output with
real-time updates (300ms debounce). Status bar displays file count,
token estimate, active target, and output format — all updating as
options change. Token count warns when exceeding target limit.
Clicking target scrolls to config, clicking format cycles options.

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

View file

@ -2,7 +2,7 @@ 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';
import { OutputTarget, OutputFormat, getTargetIcon, formatTokenCount } from './targets';
interface FolderConfig {
name: string;
@ -87,10 +87,26 @@ export class ContextGeneratorModal extends Modal {
selectedTemplateId: string | null = null;
selectedTargetIds: Set<string> = new Set();
// Preview pane state
private previewContent: HTMLElement | null = null;
private previewLineInfo: HTMLElement | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private previewOpen: boolean = false;
private static readonly PREVIEW_LINES = 30;
private static readonly DEBOUNCE_MS = 300;
// Status bar DOM references
private statusFileCount: HTMLElement | null = null;
private statusTokens: HTMLElement | null = null;
private statusTarget: HTMLElement | null = null;
private statusFormat: HTMLElement | null = null;
private targetsHeadingEl: HTMLElement | null = null;
constructor(app: App, plugin: ClaudeContextPlugin) {
super(app);
this.plugin = plugin;
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
this.previewOpen = this.plugin.settings.generatorPreviewOpen ?? false;
this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' }));
// Initialize with default template
this.selectedTemplateId = this.plugin.settings.defaultTemplateId;
@ -108,6 +124,39 @@ export class ContextGeneratorModal extends Modal {
contentEl.createEl('h2', { text: 'Context Generator' });
// === STATUS BAR ===
const statusBar = contentEl.createDiv({ cls: 'cc-gen-statusbar' });
this.statusFileCount = statusBar.createEl('span', { cls: 'cc-statusbar-item' });
statusBar.createEl('span', { cls: 'cc-statusbar-sep', text: '\u00B7' });
this.statusTokens = statusBar.createEl('span', { cls: 'cc-statusbar-item cc-statusbar-tokens' });
statusBar.createEl('span', { cls: 'cc-statusbar-sep', text: '\u00B7' });
this.statusTarget = statusBar.createEl('span', { cls: 'cc-statusbar-item cc-statusbar-clickable' });
statusBar.createEl('span', { cls: 'cc-statusbar-sep', text: '\u00B7' });
this.statusFormat = statusBar.createEl('span', { cls: 'cc-statusbar-item cc-statusbar-clickable' });
// Click target label → scroll to Output Targets section
this.statusTarget.addEventListener('click', () => {
if (this.targetsHeadingEl) {
this.targetsHeadingEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
// Click format label → cycle through formats on primary target
this.statusFormat.addEventListener('click', () => {
const primary = this.getPrimaryTarget();
if (!primary) return;
const formats: OutputFormat[] = ['markdown', 'xml', 'plain'];
const idx = formats.indexOf(primary.format);
primary.format = formats[(idx + 1) % formats.length] ?? 'markdown';
this.plugin.saveSettings();
this.updateStatusBar();
this.schedulePreviewUpdate();
});
// Initial status bar render
this.updateStatusBar();
// === TWO-ZONE GRID LAYOUT ===
const layout = contentEl.createDiv({ cls: 'cc-gen-layout' });
const selectionZone = layout.createDiv({ cls: 'cc-gen-zone cc-gen-selection' });
@ -124,31 +173,31 @@ export class ContextGeneratorModal extends Modal {
.setName('conventions.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.conventions)
.onChange(v => this.config.generateFiles.conventions = v));
.onChange(v => { this.config.generateFiles.conventions = v; this.schedulePreviewUpdate(); }));
new Setting(selectionZone)
.setName('structure.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.structure)
.onChange(v => this.config.generateFiles.structure = v));
.onChange(v => { this.config.generateFiles.structure = v; this.schedulePreviewUpdate(); }));
new Setting(selectionZone)
.setName('workflows.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.workflows)
.onChange(v => this.config.generateFiles.workflows = v));
.onChange(v => { this.config.generateFiles.workflows = v; this.schedulePreviewUpdate(); }));
new Setting(selectionZone)
.setName('templates.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.templates)
.onChange(v => this.config.generateFiles.templates = v));
.onChange(v => { this.config.generateFiles.templates = v; this.schedulePreviewUpdate(); }));
new Setting(selectionZone)
.setName('examples.md')
.addToggle(toggle => toggle
.setValue(this.config.generateFiles.examples)
.onChange(v => this.config.generateFiles.examples = v));
.onChange(v => { this.config.generateFiles.examples = v; this.schedulePreviewUpdate(); }));
// === FOLDER STRUCTURE ===
selectionZone.createEl('h3', { text: 'Folder structure' });
@ -170,7 +219,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('flat', 'Flat (#tag)')
.addOption('none', 'No tags')
.setValue(this.config.tagsStyle)
.onChange(v => this.config.tagsStyle = v));
.onChange(v => { this.config.tagsStyle = v; this.schedulePreviewUpdate(); }));
new Setting(selectionZone)
.setName('Predefined tags')
@ -180,6 +229,7 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.config.customTags.join(', '))
.onChange(v => {
this.config.customTags = v.split(',').map(s => s.trim()).filter(s => s);
this.schedulePreviewUpdate();
}));
// === FRONTMATTER SECTION ===
@ -192,6 +242,7 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.config.frontmatterFields.join(', '))
.onChange(v => {
this.config.frontmatterFields = v.split(',').map(s => s.trim()).filter(s => s);
this.schedulePreviewUpdate();
}));
// === NOTE TEMPLATES ===
@ -229,7 +280,7 @@ export class ContextGeneratorModal extends Modal {
.addTextArea(text => {
text.setPlaceholder('e.g. Personal Zettelkasten for development and knowledge management')
.setValue(this.config.vaultDescription)
.onChange(v => this.config.vaultDescription = v);
.onChange(v => { this.config.vaultDescription = v; this.schedulePreviewUpdate(); });
text.inputEl.rows = 2;
text.inputEl.style.width = '100%';
});
@ -240,7 +291,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('english', 'English')
.addOption('german', 'Deutsch')
.setValue(this.config.language)
.onChange(v => this.config.language = v));
.onChange(v => { this.config.language = v; this.schedulePreviewUpdate(); }));
// === FORMATTING ===
configZone.createEl('h3', { text: 'Formatting' });
@ -253,7 +304,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('camelCase', 'camelCase')
.addOption('free', 'Free / no convention')
.setValue(this.config.fileNaming)
.onChange(v => this.config.fileNaming = v));
.onChange(v => { this.config.fileNaming = v; this.schedulePreviewUpdate(); }));
new Setting(configZone)
.setName('Link style')
@ -261,7 +312,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('wikilinks', '[[Wikilinks]]')
.addOption('markdown', '[Markdown](links)')
.setValue(this.config.linkStyle)
.onChange(v => this.config.linkStyle = v));
.onChange(v => { this.config.linkStyle = v; this.schedulePreviewUpdate(); }));
new Setting(configZone)
.setName('Heading depth')
@ -271,7 +322,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('h4', 'H1 - H4')
.addOption('h6', 'Unlimited')
.setValue(this.config.headingDepth)
.onChange(v => this.config.headingDepth = v));
.onChange(v => { this.config.headingDepth = v; this.schedulePreviewUpdate(); }));
new Setting(configZone)
.setName('Date format')
@ -280,7 +331,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('DD.MM.YYYY', 'DD.MM.YYYY')
.addOption('MM/DD/YYYY', 'MM/DD/YYYY')
.setValue(this.config.dateFormat)
.onChange(v => this.config.dateFormat = v));
.onChange(v => { this.config.dateFormat = v; this.schedulePreviewUpdate(); }));
// === RULES ===
configZone.createEl('h3', { text: 'Rules' });
@ -293,6 +344,7 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.config.customRules.join('\n'))
.onChange(v => {
this.config.customRules = v.split('\n').map(s => s.trim()).filter(s => s);
this.schedulePreviewUpdate();
});
text.inputEl.rows = 3;
text.inputEl.style.width = '100%';
@ -305,6 +357,7 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.config.forbiddenActions.join(', '))
.onChange(v => {
this.config.forbiddenActions = v.split(',').map(s => s.trim()).filter(s => s);
this.schedulePreviewUpdate();
}));
// === ADDITIONAL CONTEXT ===
@ -316,7 +369,7 @@ export class ContextGeneratorModal extends Modal {
.addTextArea(text => {
text.setPlaceholder('Enter additional context that will be included when copying...')
.setValue(this.temporaryFreetext)
.onChange(v => this.temporaryFreetext = v);
.onChange(v => { this.temporaryFreetext = v; this.schedulePreviewUpdate(); });
text.inputEl.rows = 4;
text.inputEl.style.width = '100%';
});
@ -327,7 +380,7 @@ export class ContextGeneratorModal extends Modal {
.addOption('prefix', 'Prefix (before vault content)')
.addOption('suffix', 'Suffix (after vault content)')
.setValue(this.temporaryPosition)
.onChange(v => this.temporaryPosition = v as SourcePosition));
.onChange(v => { this.temporaryPosition = v as SourcePosition; this.schedulePreviewUpdate(); }));
new Setting(configZone)
.setName('Save as default')
@ -357,6 +410,7 @@ export class ContextGeneratorModal extends Modal {
dropdown.setValue(this.selectedTemplateId || '');
dropdown.onChange(v => {
this.selectedTemplateId = v || null;
this.schedulePreviewUpdate();
});
});
@ -369,7 +423,7 @@ export class ContextGeneratorModal extends Modal {
// === OUTPUT TARGETS ===
if (this.plugin.settings.targets.length > 0) {
configZone.createEl('h3', { text: 'Output Targets' });
this.targetsHeadingEl = configZone.createEl('h3', { text: 'Output Targets' });
const targetsContainer = configZone.createDiv({ cls: 'targets-checkboxes' });
targetsContainer.style.display = 'flex';
@ -397,6 +451,7 @@ export class ContextGeneratorModal extends Modal {
} else {
this.selectedTargetIds.delete(target.id);
}
this.schedulePreviewUpdate();
});
const icon = row.createEl('span', { text: getTargetIcon(target.format) });
@ -436,6 +491,42 @@ export class ContextGeneratorModal extends Modal {
// === TOKEN ESTIMATE ===
this.renderTokenEstimate(configZone);
// =============================================
// PREVIEW PANE (full-width, between grid and footer)
// =============================================
const previewSection = contentEl.createDiv({
cls: `cc-section cc-preview-section${this.previewOpen ? '' : ' is-collapsed'}`,
});
const previewHeader = previewSection.createDiv({ cls: 'cc-section-header' });
const previewTitle = previewHeader.createEl('span', {
text: this.previewOpen ? 'Hide Preview' : 'Show Preview',
cls: 'cc-section-title',
});
previewHeader.createEl('span', { text: '\u203A', cls: 'cc-section-chevron' });
const previewBody = previewSection.createDiv({ cls: 'cc-section-content cc-preview-body' });
this.previewContent = previewBody.createEl('pre', { cls: 'cc-preview-code' });
this.previewLineInfo = previewBody.createDiv({ cls: 'cc-preview-line-info' });
previewHeader.addEventListener('click', async () => {
this.previewOpen = !this.previewOpen;
if (this.previewOpen) {
previewSection.removeClass('is-collapsed');
previewTitle.textContent = 'Hide Preview';
this.updatePreview();
} else {
previewSection.addClass('is-collapsed');
previewTitle.textContent = 'Show Preview';
}
this.plugin.settings.generatorPreviewOpen = this.previewOpen;
await this.plugin.saveSettings();
});
if (this.previewOpen) {
this.updatePreview();
}
// =============================================
// FOOTER (full-width, below both zones)
// =============================================
@ -507,6 +598,7 @@ export class ContextGeneratorModal extends Modal {
input.style.flex = '1';
input.addEventListener('input', () => {
folder.purpose = input.value;
this.schedulePreviewUpdate();
});
}
}
@ -526,6 +618,7 @@ export class ContextGeneratorModal extends Modal {
nameInput.style.flex = '1';
nameInput.addEventListener('input', () => {
template.name = nameInput.value;
this.schedulePreviewUpdate();
});
const folderInput = row.createEl('input', { type: 'text' });
@ -534,6 +627,7 @@ export class ContextGeneratorModal extends Modal {
folderInput.style.flex = '1';
folderInput.addEventListener('input', () => {
template.folder = folderInput.value;
this.schedulePreviewUpdate();
});
const tagInput = row.createEl('input', { type: 'text' });
@ -542,12 +636,14 @@ export class ContextGeneratorModal extends Modal {
tagInput.style.flex = '1';
tagInput.addEventListener('input', () => {
template.tag = tagInput.value;
this.schedulePreviewUpdate();
});
const removeBtn = row.createEl('button', { text: '✕' });
removeBtn.addEventListener('click', () => {
this.config.templates.splice(index, 1);
this.renderTemplates(container);
this.schedulePreviewUpdate();
});
});
}
@ -850,7 +946,125 @@ Here is the content with a link to ${link}.
`;
}
private getPrimaryTarget(): OutputTarget | null {
const primaryId = this.plugin.settings.primaryTargetId;
const targets = this.plugin.settings.targets;
if (primaryId) {
return targets.find(t => t.id === primaryId && this.selectedTargetIds.has(t.id)) ?? null;
}
return targets.find(t => this.selectedTargetIds.has(t.id)) ?? null;
}
private static readonly FORMAT_LABELS: Record<OutputFormat, string> = {
markdown: 'Markdown',
xml: 'XML',
plain: 'Plain Text',
};
private updateStatusBar() {
if (!this.statusFileCount || !this.statusTokens || !this.statusTarget || !this.statusFormat) return;
// File count: VAULT.md + enabled optional files
const gf = this.config.generateFiles;
const fileCount = 1
+ (gf.conventions ? 1 : 0)
+ (gf.structure ? 1 : 0)
+ (gf.workflows ? 1 : 0)
+ (gf.templates ? 1 : 0)
+ (gf.examples ? 1 : 0);
this.statusFileCount.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
// Token estimate from generated output
const output = this.generatePreviewOutput();
const tokens = estimateTokens(output);
const primary = this.getPrimaryTarget();
const tokenText = `~${(tokens / 1000).toFixed(1)}k tokens`;
this.statusTokens.textContent = tokenText;
// Token warning color
if (primary && tokens > primary.maxTokens) {
this.statusTokens.addClass('is-warning');
} else {
this.statusTokens.removeClass('is-warning');
}
// Target name
this.statusTarget.textContent = primary
? `Target: ${primary.name}`
: 'Target: None';
// Format
if (primary) {
this.statusFormat.textContent = `Format: ${ContextGeneratorModal.FORMAT_LABELS[primary.format]}`;
} else {
this.statusFormat.textContent = 'Format: —';
}
}
private schedulePreviewUpdate() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.updatePreview();
this.updateStatusBar();
}, ContextGeneratorModal.DEBOUNCE_MS);
}
private updatePreview() {
if (!this.previewOpen || !this.previewContent || !this.previewLineInfo) return;
const fullOutput = this.generatePreviewOutput();
const allLines = fullOutput.split('\n');
const maxLines = ContextGeneratorModal.PREVIEW_LINES;
const truncated = allLines.length > maxLines;
const displayLines = truncated ? allLines.slice(0, maxLines) : allLines;
this.previewContent.textContent = displayLines.join('\n');
this.previewLineInfo.textContent = truncated
? `Showing ${maxLines} of ~${allLines.length} lines`
: `${allLines.length} lines`;
}
private generatePreviewOutput(): string {
const parts: string[] = [];
// VAULT.md is always included
parts.push(this.generateVaultMd());
if (this.config.generateFiles.conventions) {
parts.push(this.generateConventionsMd());
}
if (this.config.generateFiles.structure) {
parts.push(this.generateStructureMd());
}
if (this.config.generateFiles.workflows) {
parts.push(this.generateWorkflowsMd());
}
if (this.config.generateFiles.templates) {
parts.push(this.generateTemplatesMd());
}
if (this.config.generateFiles.examples) {
parts.push(this.generateExamplesMd());
}
return parts.join('\n\n---\n\n');
}
onClose() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.previewContent = null;
this.previewLineInfo = null;
this.statusFileCount = null;
this.statusTokens = null;
this.statusTarget = null;
this.statusFormat = null;
this.targetsHeadingEl = null;
const { contentEl } = this;
contentEl.empty();
}

View file

@ -25,6 +25,7 @@ export interface ClaudeContextSettings {
targetOutputFolder: string;
lastSettingsTab: string;
collapsedSections: string[];
generatorPreviewOpen: boolean;
}
export const DEFAULT_SETTINGS: ClaudeContextSettings = {
@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = {
targetOutputFolder: '_claude/outputs',
lastSettingsTab: 'general',
collapsedSections: [],
generatorPreviewOpen: false,
};
export type SettingsTabId = 'general' | 'sources' | 'templates' | 'output' | 'history';

View file

@ -88,7 +88,7 @@
.cc-gen-modal .modal-content {
max-height: 80vh;
overflow: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
}
@ -131,6 +131,77 @@
margin-top: 8px;
}
/* Generator status bar */
.cc-gen-statusbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
margin-bottom: 12px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
font-size: 12px;
color: var(--text-muted);
}
.cc-statusbar-item {
white-space: nowrap;
}
.cc-statusbar-sep {
color: var(--text-faint);
user-select: none;
}
.cc-statusbar-clickable {
cursor: pointer;
border-radius: 3px;
padding: 1px 4px;
transition: background 0.15s ease, color 0.15s ease;
}
.cc-statusbar-clickable:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.cc-statusbar-tokens.is-warning {
color: var(--text-warning, #e0a526);
font-weight: 600;
}
/* Generator preview pane */
.cc-preview-section {
margin-top: 12px;
}
.cc-preview-body {
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
padding: 10px;
}
.cc-preview-code {
font-family: var(--font-monospace);
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
margin: 0;
}
.cc-preview-line-info {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
text-align: right;
margin-top: 6px;
}
/* Stack on narrow viewports */
@media (max-width: 768px) {
.cc-gen-modal {