improve(modals): apply wide-modal pattern to LM Studio + Text Enhancement modals

Brings LMStudioModal, TextEnhancementModal, and TextEnhancementWithImagesModal
to the same shape as Ask Perplexity / Ask Claude / Ask Perplexica:
modalEl.addClass(...) wide-modal unlock, sectioned header/section/footer
layout, BEM-scoped class names, native Obsidian Setting components for
dropdowns and toggles, custom textareas where Setting is a poor fit,
Cmd/Ctrl+Enter submit, mobile breakpoint at 600px.

LMStudioModal: model dropdown with live taglines, Max Tokens input,
Temperature slider (0-2 with dynamic tooltip), optional System Prompt
textarea, Images + Stream toggles.

TextEnhancementModal + TextEnhancementWithImagesModal: read-only
Selected Text textarea (background-secondary tint to distinguish from
editable input) + editable prompt pre-filled from the plugin template,
button busy-state during the call.

Bonus: TextEnhancementWithImagesModal CSS class name now matches the
file/class name (was 'get-related-images-modal' on the .ts side while
the file was text-enhancement-with-images-modal.css).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-02 18:12:52 -05:00
parent 19b300c10c
commit ebebfa9f21
7 changed files with 881 additions and 1188 deletions

View file

@ -1,162 +1,217 @@
import type { App, Editor } from 'obsidian';
import { Modal, Notice } from 'obsidian';
import { Modal, Notice, Setting } from 'obsidian';
import type { LMStudioService, LMStudioOptions } from '../services/lmStudioService';
import type { PromptsService } from '../services/promptsService';
const LMSTUDIO_MODELS: Array<{ value: string; label: string; tagline: string }> = [
{ value: 'ibm/granite-3.2-8b', label: 'IBM Granite 3.2 8B', tagline: 'IBM Granite — strong on code and structured reasoning.' },
{ value: 'microsoft/phi-4-reasoning-plus', label: 'Phi-4 Reasoning Plus', tagline: 'Microsoft Phi-4 — small, fast model tuned for chain-of-thought reasoning.' },
{ value: 'google/gemma-3-12b', label: 'Gemma 3 12B', tagline: 'Google Gemma 3 — solid all-rounder open model.' },
{ value: 'meta-llama/llama-3.2-3b-instruct', label: 'Llama 3.2 3B Instruct', tagline: 'Meta Llama 3.2 — small instruction-tuned model, fastest of the bundled choices.' },
{ value: 'custom-model', label: 'custom-model', tagline: 'Generic placeholder — LM Studio will route this to whichever model is currently loaded.' },
];
const DEFAULT_MODEL = 'ibm/granite-3.2-8b';
const DEFAULT_MAX_TOKENS = 2048;
const DEFAULT_TEMPERATURE = 0.7;
export class LMStudioModal extends Modal {
private editor: Editor;
private lmStudioService: LMStudioService;
private promptsService: PromptsService;
private queryInput!: HTMLTextAreaElement;
private modelSelect!: HTMLSelectElement;
private streamToggle!: HTMLInputElement;
private maxTokensInput!: HTMLInputElement;
private temperatureInput!: HTMLInputElement;
private systemPromptInput!: HTMLTextAreaElement;
private imagesToggle!: HTMLInputElement;
constructor(app: App, editor: Editor, lmStudioService: LMStudioService, promptsService: PromptsService) {
private query = '';
private model: string = DEFAULT_MODEL;
private systemPrompt = '';
private maxTokens: number = DEFAULT_MAX_TOKENS;
private temperature: number = DEFAULT_TEMPERATURE;
private images = false;
private stream = true;
private modelDescEl: HTMLElement | null = null;
constructor(
app: App,
editor: Editor,
lmStudioService: LMStudioService,
promptsService: PromptsService
) {
super(app);
this.editor = editor;
this.lmStudioService = lmStudioService;
this.promptsService = promptsService;
}
onOpen() {
const {contentEl} = this;
contentEl.addClass('lmstudio-modal');
contentEl.createEl('h2', {text: 'Ask LM Studio'});
const form = contentEl.createEl('form');
// Query input
const queryDiv = form.createDiv({cls: 'setting-item'});
queryDiv.createEl('label', {text: 'Your Question'});
this.queryInput = queryDiv.createEl('textarea', {
cls: 'text-input',
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass('lmstudio-modal');
contentEl.empty();
// ----- Header -----
const header = contentEl.createDiv({ cls: 'lmstudio-modal__header' });
header.createEl('h2', { text: 'Ask LM Studio', cls: 'lmstudio-modal__title' });
header.createEl('p', {
cls: 'lmstudio-modal__subtitle',
text: 'Run queries against your local LM Studio server. Streams into the active note at the cursor.',
});
// ----- Question -----
const querySection = contentEl.createDiv({ cls: 'lmstudio-modal__section' });
querySection.createEl('label', {
text: 'Question',
cls: 'lmstudio-modal__label',
attr: { for: 'lmstudio-modal-query' },
});
const queryTextarea = querySection.createEl('textarea', {
cls: 'lmstudio-modal__textarea',
attr: {
rows: '4',
placeholder: this.promptsService.getLMStudioQueryPlaceholder()
}
});
// Model selection
const modelDiv = form.createDiv({cls: 'setting-item'});
modelDiv.createEl('label', {text: 'Model'});
this.modelSelect = modelDiv.createEl('select', {cls: 'dropdown'});
// Use common LM Studio models - these would be dynamically loaded ideally
['ibm/granite-3.2-8b', 'microsoft/phi-4-reasoning-plus', 'google/gemma-3-12b', 'meta-llama/llama-3.2-3b-instruct', 'custom-model'].forEach(model => {
const option = this.modelSelect.createEl('option', {value: model, text: model});
if (model === 'ibm/granite-3.2-8b') option.selected = true;
});
// System prompt
const systemDiv = form.createDiv({cls: 'setting-item'});
systemDiv.createEl('label', {text: 'System Prompt (Optional)'});
this.systemPromptInput = systemDiv.createEl('textarea', {
cls: 'text-input system-prompt-input',
attr: {
rows: '2',
placeholder: this.promptsService.getLMStudioSystemPromptPlaceholder()
}
});
// Max tokens
const maxTokensDiv = form.createDiv({cls: 'setting-item'});
maxTokensDiv.createEl('label', {text: 'Max Tokens'});
this.maxTokensInput = maxTokensDiv.createEl('input', {
type: 'number',
value: '2048',
cls: 'text-input'
});
// Temperature
const tempDiv = form.createDiv({cls: 'setting-item'});
tempDiv.createEl('label', {text: 'Temperature (0.0 - 2.0)'});
this.temperatureInput = tempDiv.createEl('input', {
type: 'number',
value: '0.7',
attr: {
step: '0.1',
min: '0',
max: '2'
id: 'lmstudio-modal-query',
rows: '6',
placeholder: this.promptsService.getLMStudioQueryPlaceholder(),
},
cls: 'text-input'
});
queryTextarea.value = this.query;
queryTextarea.addEventListener('input', () => {
this.query = queryTextarea.value;
});
queryTextarea.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void this.onSubmit();
}
});
// Images toggle
const imagesDiv = form.createDiv({cls: 'setting-item'});
const imagesLabel = imagesDiv.createEl('label');
this.imagesToggle = imagesLabel.createEl('input', {type: 'checkbox'});
this.imagesToggle.checked = false;
imagesLabel.createSpan({text: ' Include Images'});
// Add description for images toggle
const imagesDesc = imagesDiv.createDiv({cls: 'setting-item-description images-description'});
imagesDesc.textContent = this.promptsService.getImagesToggleGenericDescription();
// ----- Model -----
const modelSection = contentEl.createDiv({ cls: 'lmstudio-modal__section' });
modelSection.createEl('h3', { text: 'Model', cls: 'lmstudio-modal__section-title' });
// Stream toggle
const streamDiv = form.createDiv({cls: 'setting-item'});
const streamLabel = streamDiv.createEl('label');
this.streamToggle = streamLabel.createEl('input', {type: 'checkbox'});
this.streamToggle.checked = true;
streamLabel.createSpan({text: ' Stream response'});
const buttonDiv = contentEl.createDiv({cls: 'setting-item'});
const askButton = buttonDiv.createEl('button', {
const modelSetting = new Setting(modelSection)
.setName('Model')
.setDesc(this.modelTagline(this.model))
.addDropdown(dd => {
LMSTUDIO_MODELS.forEach(({ value, label }) => dd.addOption(value, label));
dd.setValue(this.model);
dd.onChange((value) => {
this.model = value;
if (this.modelDescEl) this.modelDescEl.textContent = this.modelTagline(value);
});
});
this.modelDescEl = modelSetting.descEl;
new Setting(modelSection)
.setName('Max Tokens')
.setDesc('Upper bound on response length. Local models cap lower than cloud — 2048 is a safe default.')
.addText(t => t
.setValue(String(this.maxTokens))
.onChange(v => {
const parsed = parseInt(v, 10);
this.maxTokens = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_TOKENS;
}));
new Setting(modelSection)
.setName('Temperature')
.setDesc('Higher = more creative / varied. 0.7 is the conventional default. Slide to 0 for deterministic output.')
.addSlider(s => s
.setLimits(0, 2, 0.1)
.setValue(this.temperature)
.setDynamicTooltip()
.onChange(v => { this.temperature = v; }));
// ----- System Prompt (multi-line, doesn't fit a Setting row) -----
const systemSection = contentEl.createDiv({ cls: 'lmstudio-modal__section' });
systemSection.createEl('h3', { text: 'System Prompt (Optional)', cls: 'lmstudio-modal__section-title' });
systemSection.createEl('p', {
cls: 'lmstudio-modal__hint',
text: 'Override the default system prompt for this request. Leave blank to use the plugin default.',
});
const systemTextarea = systemSection.createEl('textarea', {
cls: 'lmstudio-modal__textarea lmstudio-modal__textarea--system',
attr: {
rows: '3',
placeholder: this.promptsService.getLMStudioSystemPromptPlaceholder(),
},
});
systemTextarea.value = this.systemPrompt;
systemTextarea.addEventListener('input', () => {
this.systemPrompt = systemTextarea.value;
});
// ----- Returns -----
const returnsSection = contentEl.createDiv({ cls: 'lmstudio-modal__section' });
returnsSection.createEl('h3', { text: 'Include in response', cls: 'lmstudio-modal__section-title' });
new Setting(returnsSection)
.setName('Images')
.setDesc(this.promptsService.getImagesToggleGenericDescription())
.addToggle(t => t
.setValue(this.images)
.onChange(v => { this.images = v; }));
// ----- Behavior -----
const behaviorSection = contentEl.createDiv({ cls: 'lmstudio-modal__section' });
behaviorSection.createEl('h3', { text: 'Behavior', cls: 'lmstudio-modal__section-title' });
new Setting(behaviorSection)
.setName('Stream Response')
.setDesc('Write tokens into the note as they arrive — recommended for long answers and slow local models.')
.addToggle(t => t
.setValue(this.stream)
.onChange(v => { this.stream = v; }));
// ----- Footer -----
const footer = contentEl.createDiv({ cls: 'lmstudio-modal__footer' });
const cancelBtn = footer.createEl('button', {
text: 'Cancel',
cls: 'lmstudio-modal__button',
});
cancelBtn.addEventListener('click', () => this.close());
const askBtn = footer.createEl('button', {
text: 'Ask LM Studio',
cls: 'mod-cta'
cls: 'lmstudio-modal__button mod-cta',
});
form.onsubmit = (e) => {
e.preventDefault();
void this.onSubmit();
};
askButton.onclick = () => this.onSubmit();
// Focus on the query input
setTimeout(() => this.queryInput.focus(), 100);
askBtn.addEventListener('click', () => void this.onSubmit());
setTimeout(() => queryTextarea.focus(), 50);
}
async onSubmit() {
const query = this.queryInput.value.trim();
if (!query) {
private modelTagline(value: string): string {
return LMSTUDIO_MODELS.find(m => m.value === value)?.tagline ?? '';
}
private async onSubmit(): Promise<void> {
const trimmed = this.query.trim();
if (!trimmed) {
new Notice(this.promptsService.getEnterQuestionNotice());
return;
}
// If images are enabled, add image markers to the query
let processedQuery = query;
if (this.imagesToggle.checked) {
processedQuery = `${query}
${this.promptsService.getImageReferencesPrompt()}`;
let processedQuery = trimmed;
if (this.images) {
processedQuery = `${trimmed}\n\n${this.promptsService.getImageReferencesPrompt()}`;
}
const options: LMStudioOptions = {
max_tokens: parseInt(this.maxTokensInput.value) || 2048,
temperature: parseFloat(this.temperatureInput.value) || 0.7,
return_images: this.imagesToggle.checked
max_tokens: this.maxTokens,
temperature: this.temperature,
return_images: this.images,
};
const systemPrompt = this.systemPromptInput.value.trim();
if (systemPrompt) {
options.system_prompt = systemPrompt;
const trimmedSystemPrompt = this.systemPrompt.trim();
if (trimmedSystemPrompt) {
options.system_prompt = trimmedSystemPrompt;
}
this.close();
await this.lmStudioService.queryLMStudio(
processedQuery,
this.modelSelect.value,
this.streamToggle.checked,
this.editor,
processedQuery,
this.model,
this.stream,
this.editor,
options
);
}
onClose() {
const {contentEl} = this;
contentEl.empty();
onClose(): void {
this.contentEl.empty();
}
}
}

View file

@ -8,120 +8,141 @@ export class TextEnhancementModal extends Modal {
protected perplexityService: PerplexityService;
protected promptsService: PromptsService;
protected selectedText: string;
protected promptTextArea!: HTMLTextAreaElement;
protected enhanceButton!: HTMLButtonElement;
constructor(app: App, editor: Editor, perplexityService: PerplexityService, promptsService: PromptsService, selectedText: string) {
private prompt: string;
private enhanceBtn!: HTMLButtonElement;
constructor(
app: App,
editor: Editor,
perplexityService: PerplexityService,
promptsService: PromptsService,
selectedText: string
) {
super(app);
this.editor = editor;
this.perplexityService = perplexityService;
this.promptsService = promptsService;
this.selectedText = selectedText;
this.prompt = this.promptsService.getEnhanceTemplate(this.selectedText);
}
onOpen() {
const {contentEl} = this;
contentEl.addClass('text-enhancement-modal');
contentEl.createEl('h2', {text: 'Enhance Text with Perplexity'});
const form = contentEl.createEl('form');
// Original text display
const originalTextDiv = form.createDiv({cls: 'setting-item'});
originalTextDiv.createEl('label', {text: 'Selected Text'});
const originalTextArea = originalTextDiv.createEl('textarea', {
cls: 'text-input',
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass('text-enhancement-modal');
contentEl.empty();
// ----- Header -----
const header = contentEl.createDiv({ cls: 'text-enhancement-modal__header' });
header.createEl('h2', { text: 'Enhance Text with Perplexity', cls: 'text-enhancement-modal__title' });
header.createEl('p', {
cls: 'text-enhancement-modal__subtitle',
text: 'Rewrite or expand selected text via Perplexity (sonar-pro). Streams into the active note at the cursor with Citations.',
});
// ----- Selected Text (read-only) -----
const selectedSection = contentEl.createDiv({ cls: 'text-enhancement-modal__section' });
selectedSection.createEl('label', {
text: 'Selected Text',
cls: 'text-enhancement-modal__label',
});
const selectedTextarea = selectedSection.createEl('textarea', {
cls: 'text-enhancement-modal__textarea text-enhancement-modal__textarea--readonly',
attr: {
rows: '6',
readonly: 'readonly',
},
});
selectedTextarea.value = this.selectedText;
// ----- Enhancement Prompt (editable) -----
const promptSection = contentEl.createDiv({ cls: 'text-enhancement-modal__section' });
promptSection.createEl('label', {
text: 'Enhancement Prompt',
cls: 'text-enhancement-modal__label',
attr: { for: 'text-enhancement-modal-prompt' },
});
promptSection.createEl('p', {
cls: 'text-enhancement-modal__hint',
text: 'Pre-filled from the plugin template — edit to refine the rewrite instructions before submitting.',
});
const promptTextarea = promptSection.createEl('textarea', {
cls: 'text-enhancement-modal__textarea',
attr: {
id: 'text-enhancement-modal-prompt',
rows: '8',
readonly: 'readonly'
placeholder: 'Enter your enhancement prompt…',
},
});
promptTextarea.value = this.prompt;
promptTextarea.addEventListener('input', () => {
this.prompt = promptTextarea.value;
});
promptTextarea.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void this.onSubmit();
}
});
originalTextArea.value = this.selectedText;
originalTextArea.style.backgroundColor = 'var(--background-secondary)';
originalTextArea.style.color = 'var(--text-muted)';
// Editable prompt
const promptDiv = form.createDiv({cls: 'setting-item'});
promptDiv.createEl('label', {text: 'Enhancement Prompt'});
this.promptTextArea = promptDiv.createEl('textarea', {
cls: 'text-input',
attr: {
rows: '8',
placeholder: 'Enter your enhancement prompt...'
}
});
// Pre-populate with the enhancement prompt template
const enhancementPrompt = this.promptsService.getEnhanceTemplate(this.selectedText);
this.promptTextArea.value = enhancementPrompt;
// Buttons container
const buttonsDiv = form.createDiv({cls: 'setting-item'});
buttonsDiv.style.display = 'flex';
buttonsDiv.style.gap = '10px';
buttonsDiv.style.flexWrap = 'wrap';
// Enhance button
this.enhanceButton = buttonsDiv.createEl('button', {
text: 'Enhance Text',
cls: 'mod-cta'
});
this.enhanceButton.onclick = async (e) => {
e.preventDefault();
await this.enhanceText();
};
// Close button
const closeButton = buttonsDiv.createEl('button', {
// ----- Footer -----
const footer = contentEl.createDiv({ cls: 'text-enhancement-modal__footer' });
const cancelBtn = footer.createEl('button', {
text: 'Cancel',
cls: 'mod-cta'
cls: 'text-enhancement-modal__button',
});
closeButton.onclick = (e) => {
e.preventDefault();
this.close();
};
cancelBtn.addEventListener('click', () => this.close());
this.enhanceBtn = footer.createEl('button', {
text: 'Enhance Text',
cls: 'text-enhancement-modal__button mod-cta',
});
this.enhanceBtn.addEventListener('click', () => void this.onSubmit());
setTimeout(() => promptTextarea.focus(), 50);
}
private async enhanceText(): Promise<void> {
private async onSubmit(): Promise<void> {
const trimmed = this.prompt.trim();
if (!trimmed) {
new Notice('Please enter an enhancement prompt.');
return;
}
try {
this.enhanceButton.disabled = true;
this.enhanceButton.textContent = 'Enhancing...';
// Use the editable prompt from the textarea
const enhancementPrompt = this.promptTextArea.value;
// Close the modal immediately so user can see the streaming content
this.enhanceBtn.disabled = true;
this.enhanceBtn.textContent = 'Enhancing…';
this.close();
// Insert a new line before the enhanced text to make it clear what's new
const currentPosition = this.editor.getCursor();
this.editor.replaceRange('\n\n', currentPosition);
// Call Perplexity service directly with the real editor, always streaming
const cursor = this.editor.getCursor();
this.editor.replaceRange('\n\n', cursor);
await this.perplexityService.queryPerplexity(
enhancementPrompt,
'sonar-pro', // Use default model
true, // Always stream
trimmed,
'sonar-pro',
true,
this.editor,
{
return_citations: true,
return_images: false,
return_related_questions: false
return_related_questions: false,
}
);
new Notice('Text enhancement completed!');
new Notice('Text enhancement completed.');
} catch (error) {
console.error('Error enhancing text:', error);
new Notice('Failed to enhance text. Check console for details.');
} finally {
this.enhanceButton.disabled = false;
this.enhanceButton.textContent = 'Enhance Text';
if (this.enhanceBtn) {
this.enhanceBtn.disabled = false;
this.enhanceBtn.textContent = 'Enhance Text';
}
}
}
onClose() {
const {contentEl} = this;
contentEl.empty();
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -8,120 +8,144 @@ export class TextEnhancementWithImagesModal extends Modal {
protected perplexityService: PerplexityService;
protected promptsService: PromptsService;
protected selectedText: string;
protected promptTextArea!: HTMLTextAreaElement;
protected enhanceButton!: HTMLButtonElement;
constructor(app: App, editor: Editor, perplexityService: PerplexityService, promptsService: PromptsService, selectedText: string) {
private prompt: string;
private fetchBtn!: HTMLButtonElement;
constructor(
app: App,
editor: Editor,
perplexityService: PerplexityService,
promptsService: PromptsService,
selectedText: string
) {
super(app);
this.editor = editor;
this.perplexityService = perplexityService;
this.promptsService = promptsService;
this.selectedText = selectedText;
this.prompt = this.promptsService.getEnhanceWithImagesTemplate(this.selectedText);
}
onOpen() {
const {contentEl} = this;
contentEl.addClass('get-related-images-modal');
contentEl.createEl('h2', {text: 'Get Related Images'});
const form = contentEl.createEl('form');
// Original text display
const originalTextDiv = form.createDiv({cls: 'setting-item'});
originalTextDiv.createEl('label', {text: 'Selected Text'});
const originalTextArea = originalTextDiv.createEl('textarea', {
cls: 'text-input',
attr: {
rows: '8',
readonly: 'readonly'
}
});
originalTextArea.value = this.selectedText;
originalTextArea.style.backgroundColor = 'var(--background-secondary)';
originalTextArea.style.color = 'var(--text-muted)';
// Editable prompt
const promptDiv = form.createDiv({cls: 'setting-item'});
promptDiv.createEl('label', {text: 'Image Request Prompt'});
this.promptTextArea = promptDiv.createEl('textarea', {
cls: 'text-input',
attr: {
rows: '8',
placeholder: 'Enter your image request prompt...'
}
});
// Pre-populate with the image request prompt template
const imageRequestPrompt = this.promptsService.getEnhanceWithImagesTemplate(this.selectedText);
this.promptTextArea.value = imageRequestPrompt;
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass('text-enhancement-with-images-modal');
contentEl.empty();
// Buttons container
const buttonsDiv = form.createDiv({cls: 'setting-item'});
buttonsDiv.style.display = 'flex';
buttonsDiv.style.gap = '10px';
buttonsDiv.style.flexWrap = 'wrap';
// Enhance button
this.enhanceButton = buttonsDiv.createEl('button', {
// ----- Header -----
const header = contentEl.createDiv({ cls: 'text-enhancement-with-images-modal__header' });
header.createEl('h2', {
text: 'Get Related Images',
cls: 'mod-cta'
cls: 'text-enhancement-with-images-modal__title',
});
this.enhanceButton.onclick = async (e) => {
e.preventDefault();
await this.enhanceTextWithImages();
};
// Close button
const closeButton = buttonsDiv.createEl('button', {
header.createEl('p', {
cls: 'text-enhancement-with-images-modal__subtitle',
text: 'Find images related to selected text via Perplexity (sonar-pro). Streams image markers into the active note at the cursor.',
});
// ----- Selected Text (read-only) -----
const selectedSection = contentEl.createDiv({ cls: 'text-enhancement-with-images-modal__section' });
selectedSection.createEl('label', {
text: 'Selected Text',
cls: 'text-enhancement-with-images-modal__label',
});
const selectedTextarea = selectedSection.createEl('textarea', {
cls: 'text-enhancement-with-images-modal__textarea text-enhancement-with-images-modal__textarea--readonly',
attr: {
rows: '6',
readonly: 'readonly',
},
});
selectedTextarea.value = this.selectedText;
// ----- Image Request Prompt (editable) -----
const promptSection = contentEl.createDiv({ cls: 'text-enhancement-with-images-modal__section' });
promptSection.createEl('label', {
text: 'Image Request Prompt',
cls: 'text-enhancement-with-images-modal__label',
attr: { for: 'text-enhancement-with-images-modal-prompt' },
});
promptSection.createEl('p', {
cls: 'text-enhancement-with-images-modal__hint',
text: 'Pre-filled from the plugin template — edit to refine which kinds of images to surface.',
});
const promptTextarea = promptSection.createEl('textarea', {
cls: 'text-enhancement-with-images-modal__textarea',
attr: {
id: 'text-enhancement-with-images-modal-prompt',
rows: '8',
placeholder: 'Enter your image request prompt…',
},
});
promptTextarea.value = this.prompt;
promptTextarea.addEventListener('input', () => {
this.prompt = promptTextarea.value;
});
promptTextarea.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void this.onSubmit();
}
});
// ----- Footer -----
const footer = contentEl.createDiv({ cls: 'text-enhancement-with-images-modal__footer' });
const cancelBtn = footer.createEl('button', {
text: 'Cancel',
cls: 'mod-cta'
cls: 'text-enhancement-with-images-modal__button',
});
closeButton.onclick = (e) => {
e.preventDefault();
this.close();
};
cancelBtn.addEventListener('click', () => this.close());
this.fetchBtn = footer.createEl('button', {
text: 'Get Related Images',
cls: 'text-enhancement-with-images-modal__button mod-cta',
});
this.fetchBtn.addEventListener('click', () => void this.onSubmit());
setTimeout(() => promptTextarea.focus(), 50);
}
private async enhanceTextWithImages(): Promise<void> {
private async onSubmit(): Promise<void> {
const trimmed = this.prompt.trim();
if (!trimmed) {
new Notice('Please enter an image request prompt.');
return;
}
try {
this.enhanceButton.disabled = true;
this.enhanceButton.textContent = 'Getting Related Images...';
// Use the editable prompt from the textarea
const imagePrompt = this.promptTextArea.value;
// Close the modal immediately so user can see the streaming content
this.fetchBtn.disabled = true;
this.fetchBtn.textContent = 'Getting Related Images…';
this.close();
// Insert a new line before the images to make it clear what's new
const currentPosition = this.editor.getCursor();
this.editor.replaceRange('\n\n', currentPosition);
// Call Perplexity service directly with the real editor, always streaming and with images enabled
const cursor = this.editor.getCursor();
this.editor.replaceRange('\n\n', cursor);
await this.perplexityService.queryPerplexity(
imagePrompt,
'sonar-pro', // Use default model
true, // Always stream
trimmed,
'sonar-pro',
true,
this.editor,
{
return_citations: false, // No citations needed for image-only response
return_images: true, // Enable images for this enhancement
return_related_questions: false
return_citations: false,
return_images: true,
return_related_questions: false,
}
);
new Notice('Related images added successfully!');
new Notice('Related images added successfully.');
} catch (error) {
console.error('Error getting related images:', error);
new Notice('Failed to get related images. Check console for details.');
} finally {
this.enhanceButton.disabled = false;
this.enhanceButton.textContent = 'Get Related Images';
if (this.fetchBtn) {
this.fetchBtn.disabled = false;
this.fetchBtn.textContent = 'Get Related Images';
}
}
}
onClose() {
const {contentEl} = this;
contentEl.empty();
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,12 +1,193 @@
/* LMStudioModal Styles */
/* ============================================================
LM Studio Modal local LLM query composer
Built on Obsidian's native Setting components for theming
parity, with custom textareas for the question + system prompt.
============================================================ */
.lmstudio-modal .text-input {
width: 100%;
margin: 8px 0;
padding: 12px;
.lmstudio-modal {
width: 90vw;
max-width: 640px;
}
.lmstudio-modal .system-prompt-input {
.lmstudio-modal .modal-content {
padding: 0;
}
/* ----- Header ----- */
.lmstudio-modal__header {
padding: 24px 28px 16px;
border-bottom: 1px solid var(--background-modifier-border);
}
.lmstudio-modal__title {
margin: 0 0 6px;
font-size: 1.5em;
font-weight: 600;
color: var(--text-normal);
letter-spacing: -0.01em;
}
.lmstudio-modal__subtitle {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-muted);
}
/* ----- Sections ----- */
.lmstudio-modal__section {
padding: 18px 28px 4px;
}
.lmstudio-modal__section + .lmstudio-modal__section {
border-top: 1px solid var(--background-modifier-border-hover);
margin-top: 4px;
}
.lmstudio-modal__section-title {
margin: 0 0 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.lmstudio-modal__section .setting-item {
padding: 10px 0;
border-top: none;
}
.lmstudio-modal__section .setting-item + .setting-item {
border-top: 1px solid var(--background-modifier-border-hover);
}
/* ----- Inline hint paragraph ----- */
.lmstudio-modal__hint {
margin: -4px 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
/* ----- Question label + textareas ----- */
.lmstudio-modal__label {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.lmstudio-modal__textarea {
width: 100%;
min-height: 60px;
}
min-height: 120px;
padding: 12px 14px;
font-family: var(--font-text);
font-size: 14px;
line-height: 1.5;
color: var(--text-normal);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
resize: vertical;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.lmstudio-modal__textarea:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}
.lmstudio-modal__textarea::placeholder {
color: var(--text-faint);
}
/* System-prompt textarea is shorter than the main question textarea */
.lmstudio-modal__textarea--system {
min-height: 72px;
font-family: var(--font-monospace);
font-size: 13px;
}
/* ----- Footer / actions ----- */
.lmstudio-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 28px 24px;
margin-top: 12px;
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
border-bottom-left-radius: var(--modal-radius, 8px);
border-bottom-right-radius: var(--modal-radius, 8px);
}
.lmstudio-modal__button {
padding: 8px 18px;
font-size: 14px;
font-weight: 500;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background-color: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.lmstudio-modal__button:hover {
background-color: var(--background-modifier-hover);
}
.lmstudio-modal__button:active {
transform: translateY(1px);
}
.lmstudio-modal__button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.lmstudio-modal__button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
}
.lmstudio-modal__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ----- Responsive ----- */
@media (max-width: 600px) {
.lmstudio-modal {
width: 95vw;
max-width: none;
}
.lmstudio-modal__header,
.lmstudio-modal__section,
.lmstudio-modal__footer {
padding-left: 18px;
padding-right: 18px;
}
.lmstudio-modal__footer {
flex-direction: column-reverse;
}
.lmstudio-modal__button {
width: 100%;
}
}

View file

@ -1,106 +1,180 @@
/* ============================================================
Text Enhancement Modal selection rewrite/expand composer
Built on the unified wide-modal pattern (see
context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md).
============================================================ */
.text-enhancement-modal {
max-width: 800px;
width: 90vw;
max-width: 720px;
}
.text-enhancement-modal .setting-item {
margin-bottom: 20px;
.text-enhancement-modal .modal-content {
padding: 0;
}
.text-enhancement-modal .setting-item label {
display: block;
margin-bottom: 5px;
/* ----- Header ----- */
.text-enhancement-modal__header {
padding: 24px 28px 16px;
border-bottom: 1px solid var(--background-modifier-border);
}
.text-enhancement-modal__title {
margin: 0 0 6px;
font-size: 1.5em;
font-weight: 600;
color: var(--text-normal);
letter-spacing: -0.01em;
}
.text-enhancement-modal .text-input {
width: 40%;
min-height: 100px;
font-family: var(--font-monospace);
.text-enhancement-modal__subtitle {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-muted);
}
/* ----- Sections ----- */
.text-enhancement-modal__section {
padding: 18px 28px 4px;
}
.text-enhancement-modal__section + .text-enhancement-modal__section {
border-top: 1px solid var(--background-modifier-border-hover);
margin-top: 4px;
}
/* ----- Inline hint paragraph ----- */
.text-enhancement-modal__hint {
margin: -4px 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
/* ----- Labels + textareas ----- */
.text-enhancement-modal__label {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.text-enhancement-modal__textarea {
width: 100%;
min-height: 120px;
padding: 12px 14px;
font-family: var(--font-text);
font-size: 14px;
line-height: 1.5;
color: var(--text-normal);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
resize: vertical;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.text-enhancement-modal .dropdown {
width: 100%;
padding: 8px;
.text-enhancement-modal__textarea:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}
.text-enhancement-modal__textarea::placeholder {
color: var(--text-faint);
}
/* Read-only "Selected Text" display — visually distinct from editable input */
.text-enhancement-modal__textarea--readonly {
background-color: var(--background-secondary);
color: var(--text-muted);
cursor: default;
}
.text-enhancement-modal__textarea--readonly:focus {
border-color: var(--background-modifier-border);
box-shadow: none;
}
/* ----- Footer / actions ----- */
.text-enhancement-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 28px 24px;
margin-top: 12px;
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
border-bottom-left-radius: var(--modal-radius, 8px);
border-bottom-right-radius: var(--modal-radius, 8px);
}
.text-enhancement-modal__button {
padding: 8px 18px;
font-size: 14px;
font-weight: 500;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
border-radius: 6px;
background-color: var(--background-primary);
color: var(--text-normal);
}
.text-enhancement-modal button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.text-enhancement-modal button:disabled {
.text-enhancement-modal__button:hover {
background-color: var(--background-modifier-hover);
}
.text-enhancement-modal__button:active {
transform: translateY(1px);
}
.text-enhancement-modal__button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.text-enhancement-modal__button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
}
.text-enhancement-modal__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.text-enhancement-modal button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
/* ----- Responsive ----- */
.text-enhancement-modal button.mod-cta:hover:not(:disabled) {
background-color: var(--interactive-accent-hover);
}
.text-enhancement-modal button.mod-warning {
background-color: var(--text-error);
color: var(--text-on-accent);
}
.text-enhancement-modal button.mod-warning:hover:not(:disabled) {
background-color: var(--text-error-hover);
}
.text-enhancement-modal input[type="checkbox"] {
margin-right: 8px;
}
.text-enhancement-modal .setting-item-description {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* Original text area styling */
.text-enhancement-modal .text-input[readonly] {
background-color: var(--background-secondary);
color: var(--text-muted);
border: 1px solid var(--background-modifier-border);
}
/* Enhanced text area styling */
.text-enhancement-modal .text-input:not([readonly]) {
background-color: var(--background-primary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
}
/* Responsive design */
@media (max-width: 768px) {
@media (max-width: 600px) {
.text-enhancement-modal {
width: 95vw;
max-width: none;
}
.text-enhancement-modal button {
width: 100%;
margin-bottom: 8px;
.text-enhancement-modal__header,
.text-enhancement-modal__section,
.text-enhancement-modal__footer {
padding-left: 18px;
padding-right: 18px;
}
.text-enhancement-modal .setting-item {
margin-bottom: 15px;
.text-enhancement-modal__footer {
flex-direction: column-reverse;
}
.text-enhancement-modal__button {
width: 100%;
}
}

View file

@ -1,72 +1,179 @@
.get-related-images-modal {
max-width: 600px;
/* ============================================================
Text Enhancement With Images Modal selection related images
Built on the unified wide-modal pattern (see
context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md).
============================================================ */
.text-enhancement-with-images-modal {
width: 90vw;
max-width: 720px;
}
.get-related-images-modal .setting-item {
margin-bottom: 20px;
.text-enhancement-with-images-modal .modal-content {
padding: 0;
}
.get-related-images-modal .setting-item label {
display: block;
margin-bottom: 8px;
/* ----- Header ----- */
.text-enhancement-with-images-modal__header {
padding: 24px 28px 16px;
border-bottom: 1px solid var(--background-modifier-border);
}
.text-enhancement-with-images-modal__title {
margin: 0 0 6px;
font-size: 1.5em;
font-weight: 600;
color: var(--text-normal);
letter-spacing: -0.01em;
}
.get-related-images-modal .text-input {
width: 40%;
.text-enhancement-with-images-modal__subtitle {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-muted);
}
/* ----- Sections ----- */
.text-enhancement-with-images-modal__section {
padding: 18px 28px 4px;
}
.text-enhancement-with-images-modal__section + .text-enhancement-with-images-modal__section {
border-top: 1px solid var(--background-modifier-border-hover);
margin-top: 4px;
}
/* ----- Inline hint paragraph ----- */
.text-enhancement-with-images-modal__hint {
margin: -4px 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
/* ----- Labels + textareas ----- */
.text-enhancement-with-images-modal__label {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.text-enhancement-with-images-modal__textarea {
width: 100%;
min-height: 120px;
padding: 12px;
padding: 12px 14px;
font-family: var(--font-text);
font-size: 14px;
line-height: 1.5;
color: var(--text-normal);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
resize: vertical;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.text-enhancement-with-images-modal__textarea:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}
.text-enhancement-with-images-modal__textarea::placeholder {
color: var(--text-faint);
}
.text-enhancement-with-images-modal__textarea--readonly {
background-color: var(--background-secondary);
color: var(--text-muted);
cursor: default;
}
.text-enhancement-with-images-modal__textarea--readonly:focus {
border-color: var(--background-modifier-border);
box-shadow: none;
}
/* ----- Footer / actions ----- */
.text-enhancement-with-images-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 28px 24px;
margin-top: 12px;
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
border-bottom-left-radius: var(--modal-radius, 8px);
border-bottom-right-radius: var(--modal-radius, 8px);
}
.text-enhancement-with-images-modal__button {
padding: 8px 18px;
font-size: 14px;
font-weight: 500;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background-color: var(--background-primary);
color: var(--text-normal);
font-family: var(--font-monospace);
font-size: 14px;
line-height: 1.5;
resize: vertical;
}
.get-related-images-modal .text-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.get-related-images-modal .text-input[readonly] {
background-color: var(--background-secondary);
color: var(--text-muted);
cursor: not-allowed;
}
.get-related-images-modal button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.get-related-images-modal button.mod-cta {
.text-enhancement-with-images-modal__button:hover {
background-color: var(--background-modifier-hover);
}
.text-enhancement-with-images-modal__button:active {
transform: translateY(1px);
}
.text-enhancement-with-images-modal__button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.get-related-images-modal button.mod-cta:hover {
.text-enhancement-with-images-modal__button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
}
.get-related-images-modal button:disabled {
opacity: 0.6;
.text-enhancement-with-images-modal__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.get-related-images-modal h2 {
margin-bottom: 20px;
color: var(--text-normal);
font-size: 1.5em;
font-weight: 600;
/* ----- Responsive ----- */
@media (max-width: 600px) {
.text-enhancement-with-images-modal {
width: 95vw;
max-width: none;
}
.text-enhancement-with-images-modal__header,
.text-enhancement-with-images-modal__section,
.text-enhancement-with-images-modal__footer {
padding-left: 18px;
padding-right: 18px;
}
.text-enhancement-with-images-modal__footer {
flex-direction: column-reverse;
}
.text-enhancement-with-images-modal__button {
width: 100%;
}
}

File diff suppressed because one or more lines are too long