From fb0212a3379e9de615a8125d21967ec89949e4ff Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Sun, 24 May 2026 13:36:13 -0700 Subject: [PATCH] Phase 9 --- CLAUDE.md | 4 +- docs/IMPLEMENTATION_PLAN.md | 9 + src/main.ts | 2 + src/settings/tab.ts | 544 ++++++++++++++++++++++++++++++++++++ styles.css | 165 ++++++++++- 5 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 src/settings/tab.ts diff --git a/CLAUDE.md b/CLAUDE.md index e23f7db..72362a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project state -This repo is the starting point for a planned **Voice Notes plugin for Obsidian**. The current contents are the unmodified Obsidian [sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin) boilerplate (note: [manifest.json](manifest.json) still reports `id: "sample-plugin"`, `name: "Sample Plugin"` — these need to be replaced before release). +**Implementation in progress against [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md). The live status of each phase (what's committed, what's uncommitted, architectural decisions made along the way) is in [docs/claude-scratch/STATUS.md](docs/claude-scratch/STATUS.md). Read STATUS.md first; it is more current than the rest of this section, which will be rewritten wholesale in Phase 13.** -The real spec — providers, profile system, modal UX, templates, settings layout, API request shapes — lives in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md). Treat that file as the source of truth for what to build; the code in [src/](src/) is placeholder scaffolding. +This repo is becoming the **ReWrite (Voice Notes) plugin for Obsidian**. The spec (providers, profile system, modal UX, templates, settings layout, API request shapes) lives in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md), which is still the source of truth for behavior. When implementing spec features, follow the file layout the spec prescribes (provider adapters under `src/transcription/` and `src/llm/`, factories in each `index.ts`, no provider-specific logic leaking outside its own file). diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e4c4593..bc757b5 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -76,6 +76,15 @@ src/ ## Implementation phases +### Per-phase deliverables (applies to every phase below) + +Every phase ends with the same two doc touches before it counts as done: + +1. **Update [docs/claude-scratch/STATUS.md](claude-scratch/STATUS.md)**: bump the "Updated" date, flip the phase row to committed/uncommitted, list uncommitted files, and trim the "What's left" section. This is the live tracker, so a future Claude can resume without re-reading the world. +2. **Update [CLAUDE.md](../CLAUDE.md)** if the phase changed anything CLAUDE.md describes (architecture, commands, gotchas, conventions). Per CLAUDE.md's own Documentation Maintenance rule. Phase 13 is the dedicated full refresh; phases before it should at minimum keep the doc from going stale (e.g. drop a pointer to STATUS.md if "Project state" is now wrong). + +Treat both as part of the phase, not follow-ups. The user has already had to remind once. + ### Phase 1: Scaffold rename and types (foundations) - [manifest.json](../manifest.json): `id` → `rewrite-plugin`, `name` → `ReWrite (Voice Notes)`, `version` → `0.1.0` (pre-release), `minAppVersion` → `1.4.0`, `description` → spec wording, drop `fundingUrl`/`authorUrl`/`author` Obsidian defaults, `isDesktopOnly: false`. diff --git a/src/main.ts b/src/main.ts index 9d5675f..6a8f16f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { Plugin } from 'obsidian'; import { loadSettings, saveSettings } from './settings'; +import { ReWriteSettingTab } from './settings/tab'; import { GlobalSettings } from './types'; export default class ReWritePlugin extends Plugin { @@ -7,6 +8,7 @@ export default class ReWritePlugin extends Plugin { async onload(): Promise { this.settings = await loadSettings(this); + this.addSettingTab(new ReWriteSettingTab(this.app, this)); } async saveSettings(): Promise { diff --git a/src/settings/tab.ts b/src/settings/tab.ts new file mode 100644 index 0000000..2b45681 --- /dev/null +++ b/src/settings/tab.ts @@ -0,0 +1,544 @@ +import { App, Modal, Notice, PluginSettingTab, Setting } from 'obsidian'; +import type ReWritePlugin from '../main'; +import { + ActiveProfileKind, + ActiveProfileOverride, + InsertMode, + LLMProviderID, + NoteTemplate, + ProviderFamily, + RecordingFormatPreference, + TranscriptionProviderID, +} from '../types'; +import { detectActiveProfileKind } from '../platform'; + +const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string }> = [ + { id: 'openai', label: 'OpenAI Whisper' }, + { id: 'openai-compatible', label: 'OpenAI-compatible (local server)' }, + { id: 'groq', label: 'Groq' }, + { id: 'assemblyai', label: 'AssemblyAI' }, + { id: 'deepgram', label: 'Deepgram' }, + { id: 'revai', label: 'Rev.ai' }, + { id: 'webspeech', label: 'Web Speech (browser)' }, +]; + +const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ + { id: 'anthropic', label: 'Anthropic Claude' }, + { id: 'openai', label: 'OpenAI GPT' }, + { id: 'openai-compatible', label: 'OpenAI-compatible (local server)' }, + { id: 'gemini', label: 'Google Gemini' }, + { id: 'mistral', label: 'Mistral' }, +]; + +const PROVIDER_FAMILY_OPTIONS: Array<{ id: ProviderFamily; label: string }> = [ + { id: 'openai', label: 'OpenAI' }, + { id: 'anthropic', label: 'Anthropic' }, + { id: 'groq', label: 'Groq' }, + { id: 'assemblyai', label: 'AssemblyAI' }, + { id: 'deepgram', label: 'Deepgram' }, + { id: 'revai', label: 'Rev.ai' }, + { id: 'gemini', label: 'Google Gemini' }, + { id: 'mistral', label: 'Mistral' }, +]; + +const INSERT_MODE_OPTIONS: Array<{ id: InsertMode; label: string }> = [ + { id: 'cursor', label: 'Insert at cursor' }, + { id: 'newFile', label: 'Create new file' }, + { id: 'append', label: 'Append to active note' }, +]; + +const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: string }> = [ + { id: 'webm', label: 'webm (best on Chromium/Electron)' }, + { id: 'mp4', label: 'mp4 (best on mobile/Safari)' }, +]; + +export class ReWriteSettingTab extends PluginSettingTab { + private editingTemplateId: string | null = null; + private dragSourceIndex: number | null = null; + + constructor(app: App, private readonly plugin: ReWritePlugin) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass('rewrite-settings'); + + this.renderActiveProfile(containerEl); + this.renderProfile(containerEl, 'desktop'); + this.renderProfile(containerEl, 'mobile'); + this.renderGlobalApiKeys(containerEl); + this.renderTemplates(containerEl); + this.renderRecording(containerEl); + } + + private async commit(): Promise { + await this.plugin.saveSettings(); + } + + private renderActiveProfile(parent: HTMLElement): void { + new Setting(parent).setName('Active profile').setHeading(); + const s = this.plugin.settings; + const detected = detectActiveProfileKind(s); + const detectedLabel = detected === 'desktop' ? 'Desktop' : 'Mobile'; + const overrideDesc = s.activeProfileOverride === 'auto' + ? `Auto-detected: ${detectedLabel}.` + : `Forced: ${detectedLabel}.`; + + new Setting(parent) + .setName('Profile selection') + .setDesc(overrideDesc) + .addDropdown((dd) => { + dd.addOption('auto', 'Auto-detect (recommended)'); + dd.addOption('desktop', 'Force desktop'); + dd.addOption('mobile', 'Force mobile'); + dd.setValue(s.activeProfileOverride); + dd.onChange(async (v) => { + s.activeProfileOverride = v as ActiveProfileOverride; + await this.commit(); + this.display(); + }); + }); + } + + private renderProfile(parent: HTMLElement, kind: ActiveProfileKind): void { + const profile = kind === 'desktop' + ? this.plugin.settings.desktopProfile + : this.plugin.settings.mobileProfile; + const title = kind === 'desktop' ? 'Desktop profile' : 'Mobile profile'; + new Setting(parent).setName(title).setHeading(); + + new Setting(parent) + .setName('Profile label') + .setDesc('Display name for this profile.') + .addText((t) => { + t.setValue(profile.name); + t.onChange(async (v) => { + profile.name = v; + await this.commit(); + }); + }); + + new Setting(parent) + .setName('Transcription provider') + .addDropdown((dd) => { + for (const opt of TRANSCRIPTION_OPTIONS) dd.addOption(opt.id, opt.label); + dd.setValue(profile.transcriptionProvider); + dd.onChange(async (v) => { + profile.transcriptionProvider = v as TranscriptionProviderID; + await this.commit(); + this.display(); + }); + }); + + if (profile.transcriptionProvider !== 'webspeech') { + new Setting(parent) + .setName('Transcription model') + .setDesc(transcriptionModelHint(profile.transcriptionProvider)) + .addText((t) => { + t.setValue(profile.transcriptionConfig.model); + t.onChange(async (v) => { + profile.transcriptionConfig.model = v; + await this.commit(); + }); + }); + + if (profile.transcriptionProvider === 'openai-compatible') { + new Setting(parent) + .setName('Transcription base URL') + .setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)') + .addText((t) => { + t.setValue(profile.transcriptionConfig.baseUrl); + t.onChange(async (v) => { + profile.transcriptionConfig.baseUrl = v; + await this.commit(); + }); + }); + } + + new Setting(parent) + .setName('Transcription language') + .setDesc('Optional language hint. Leave blank to auto-detect.') + .addText((t) => { + t.setValue(profile.transcriptionConfig.language); + t.onChange(async (v) => { + profile.transcriptionConfig.language = v; + await this.commit(); + }); + }); + + new Setting(parent) + .setName('Transcription API key override') + .setDesc('Optional. Leave blank to use the global key for this provider.') + .addText((t) => { + t.inputEl.type = 'password'; + t.setPlaceholder('Per-profile override'); + t.setValue(profile.transcriptionConfig.apiKey); + t.onChange(async (v) => { + profile.transcriptionConfig.apiKey = v; + await this.commit(); + }); + }); + } + + new Setting(parent) + .setName('LLM provider') + .addDropdown((dd) => { + for (const opt of LLM_OPTIONS) dd.addOption(opt.id, opt.label); + dd.setValue(profile.llmProvider); + dd.onChange(async (v) => { + profile.llmProvider = v as LLMProviderID; + await this.commit(); + this.display(); + }); + }); + + new Setting(parent) + .setName('LLM model') + .setDesc(llmModelHint(profile.llmProvider)) + .addText((t) => { + t.setValue(profile.llmConfig.model); + t.onChange(async (v) => { + profile.llmConfig.model = v; + await this.commit(); + }); + }); + + if (profile.llmProvider === 'openai-compatible') { + new Setting(parent) + .setName('LLM base URL') + .setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)') + .addText((t) => { + t.setValue(profile.llmConfig.baseUrl); + t.onChange(async (v) => { + profile.llmConfig.baseUrl = v; + await this.commit(); + }); + }); + } + + new Setting(parent) + .setName('LLM max tokens') + .setDesc('Maximum tokens for the cleanup response. Default 2048.') + .addText((t) => { + t.inputEl.type = 'number'; + t.setValue(String(profile.llmConfig.maxTokens)); + t.onChange(async (v) => { + const n = Number.parseInt(v, 10); + profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048; + await this.commit(); + }); + }); + + new Setting(parent) + .setName('LLM API key override') + .setDesc('Optional. Leave blank to use the global key for this provider.') + .addText((t) => { + t.inputEl.type = 'password'; + t.setPlaceholder('Per-profile override'); + t.setValue(profile.llmConfig.apiKey); + t.onChange(async (v) => { + profile.llmConfig.apiKey = v; + await this.commit(); + }); + }); + } + + private renderGlobalApiKeys(parent: HTMLElement): void { + new Setting(parent).setName('Global API keys').setHeading(); + parent.createEl('p', { + text: 'Shared across profiles unless overridden above. Stored securely on this device.', + cls: 'rewrite-section-desc', + }); + + for (const { id, label } of PROVIDER_FAMILY_OPTIONS) { + new Setting(parent) + .setName(label) + .addText((t) => { + t.inputEl.type = 'password'; + t.setPlaceholder('Not set'); + t.setValue(this.plugin.settings.apiKeys[id] ?? ''); + t.onChange(async (v) => { + if (v) { + this.plugin.settings.apiKeys[id] = v; + } else { + delete this.plugin.settings.apiKeys[id]; + } + await this.commit(); + }); + }); + } + } + + private renderTemplates(parent: HTMLElement): void { + new Setting(parent).setName('Templates').setHeading(); + parent.createEl('p', { + text: 'Drag the handle to reorder. Templates appear in the modal dropdown in this order.', + cls: 'rewrite-section-desc', + }); + + const templates = this.plugin.settings.templates; + const list = parent.createDiv({ cls: 'rewrite-templates-list' }); + + if (templates.length === 0) { + list.createEl('p', { + text: 'No templates configured. Add one to get started.', + cls: 'rewrite-templates-empty', + }); + } else { + for (let i = 0; i < templates.length; i++) { + const template = templates[i]; + if (!template) continue; + this.renderTemplateItem(list, template, i); + } + } + + const actions = parent.createDiv({ cls: 'rewrite-templates-actions' }); + const addBtn = actions.createEl('button', { text: 'Add template', cls: 'mod-cta' }); + addBtn.addEventListener('click', () => { + void this.addTemplate(); + }); + } + + private renderTemplateItem(parent: HTMLElement, template: NoteTemplate, index: number): void { + const item = parent.createDiv({ cls: 'rewrite-template-item' }); + item.draggable = true; + item.dataset.index = String(index); + + item.addEventListener('dragstart', (ev) => { + this.dragSourceIndex = index; + item.addClass('is-dragging'); + if (ev.dataTransfer) { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData('text/plain', String(index)); + } + }); + item.addEventListener('dragend', () => { + item.removeClass('is-dragging'); + this.dragSourceIndex = null; + }); + item.addEventListener('dragover', (ev) => { + ev.preventDefault(); + if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move'; + item.addClass('is-drop-target'); + }); + item.addEventListener('dragleave', () => { + item.removeClass('is-drop-target'); + }); + item.addEventListener('drop', (ev) => { + ev.preventDefault(); + item.removeClass('is-drop-target'); + const from = this.dragSourceIndex; + this.dragSourceIndex = null; + void this.reorderTemplate(from, index); + }); + + const header = item.createDiv({ cls: 'rewrite-template-header' }); + const handle = header.createSpan({ cls: 'rewrite-drag-handle', text: '⋮⋮' }); + handle.setAttr('aria-label', 'Drag to reorder'); + header.createSpan({ + cls: 'rewrite-template-name', + text: template.name || '(unnamed)', + }); + + const isEditing = this.editingTemplateId === template.id; + const actions = header.createDiv({ cls: 'rewrite-template-actions' }); + const editBtn = actions.createEl('button', { text: isEditing ? 'Close' : 'Edit' }); + editBtn.addEventListener('click', () => { + this.editingTemplateId = isEditing ? null : template.id; + this.display(); + }); + const deleteBtn = actions.createEl('button', { text: 'Delete' }); + deleteBtn.addEventListener('click', () => { + this.deleteTemplate(template); + }); + + if (isEditing) { + this.renderTemplateEditor(item, template); + } + } + + private renderTemplateEditor(parent: HTMLElement, template: NoteTemplate): void { + const editor = parent.createDiv({ cls: 'rewrite-template-editor' }); + + new Setting(editor) + .setName('Name') + .addText((t) => { + t.setValue(template.name); + t.onChange(async (v) => { + template.name = v; + await this.commit(); + }); + }); + + new Setting(editor) + .setName('Prompt') + .setDesc('System prompt sent to the LLM. The raw transcript is passed as the user message.') + .addTextArea((t) => { + t.setValue(template.prompt); + t.onChange(async (v) => { + template.prompt = v; + await this.commit(); + }); + t.inputEl.rows = 6; + t.inputEl.addClass('rewrite-prompt-textarea'); + }); + + new Setting(editor) + .setName('Insert mode') + .addDropdown((dd) => { + for (const opt of INSERT_MODE_OPTIONS) dd.addOption(opt.id, opt.label); + dd.setValue(template.insertMode); + dd.onChange(async (v) => { + template.insertMode = v as InsertMode; + await this.commit(); + this.display(); + }); + }); + + if (template.insertMode === 'newFile') { + new Setting(editor) + .setName('New file folder') + .setDesc('Vault-relative folder. Leave blank for vault root.') + .addText((t) => { + t.setValue(template.newFileFolder); + t.onChange(async (v) => { + template.newFileFolder = v; + await this.commit(); + }); + }); + + new Setting(editor) + .setName('New file name template') + .setDesc('Supports {{date}} (YYYY-MM-DD) and {{time}} (HHmmss).') + .addText((t) => { + t.setValue(template.newFileNameTemplate); + t.setPlaceholder('ReWrite {{date}} {{time}}'); + t.onChange(async (v) => { + template.newFileNameTemplate = v; + await this.commit(); + }); + }); + } + } + + private async addTemplate(): Promise { + const newTemplate: NoteTemplate = { + id: generateTemplateId(), + name: 'Untitled template', + prompt: 'Clean up the transcript while preserving the original meaning.', + insertMode: 'cursor', + newFileFolder: '', + newFileNameTemplate: 'ReWrite {{date}} {{time}}', + }; + this.plugin.settings.templates.push(newTemplate); + this.editingTemplateId = newTemplate.id; + await this.commit(); + this.display(); + } + + private async reorderTemplate(from: number | null, to: number): Promise { + if (from === null || from === to) return; + const templates = this.plugin.settings.templates; + if (from < 0 || from >= templates.length || to < 0 || to >= templates.length) return; + const [moved] = templates.splice(from, 1); + if (!moved) return; + templates.splice(to, 0, moved); + await this.commit(); + this.display(); + } + + private deleteTemplate(template: NoteTemplate): void { + new ConfirmModal(this.app, `Delete template "${template.name}"?`, 'Delete', async () => { + const s = this.plugin.settings; + s.templates = s.templates.filter((t) => t.id !== template.id); + if (s.defaultTemplateId === template.id) s.defaultTemplateId = ''; + if (s.lastUsedTemplateId === template.id) s.lastUsedTemplateId = ''; + if (this.editingTemplateId === template.id) this.editingTemplateId = null; + await this.commit(); + this.display(); + new Notice('Template deleted.'); + }).open(); + } + + private renderRecording(parent: HTMLElement): void { + new Setting(parent).setName('Recording').setHeading(); + new Setting(parent) + .setName('Audio format preference') + .setDesc('Web Speech ignores this. Use mp4 on iOS, otherwise webm.') + .addDropdown((dd) => { + for (const opt of RECORDING_FORMAT_OPTIONS) dd.addOption(opt.id, opt.label); + dd.setValue(this.plugin.settings.recordingFormat); + dd.onChange(async (v) => { + this.plugin.settings.recordingFormat = v as RecordingFormatPreference; + await this.commit(); + }); + }); + } +} + +class ConfirmModal extends Modal { + constructor( + app: App, + private readonly message: string, + private readonly confirmLabel: string, + private readonly onConfirm: () => void | Promise, + ) { + super(app); + } + + onOpen(): void { + this.contentEl.createEl('p', { text: this.message }); + const actions = this.contentEl.createDiv({ cls: 'rewrite-modal-actions' }); + const cancel = actions.createEl('button', { text: 'Cancel' }); + cancel.addEventListener('click', () => this.close()); + const confirm = actions.createEl('button', { text: this.confirmLabel, cls: 'mod-warning' }); + confirm.addEventListener('click', () => { + this.close(); + void this.onConfirm(); + }); + } + + onClose(): void { + this.contentEl.empty(); + } +} + +function generateTemplateId(): string { + return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function transcriptionModelHint(id: TranscriptionProviderID): string { + switch (id) { + case 'openai': + return 'e.g. whisper-1'; + case 'groq': + return 'e.g. whisper-large-v3-turbo'; + case 'assemblyai': + return 'Optional. e.g. universal or nano'; + case 'deepgram': + return 'e.g. nova-2 or nova-3'; + case 'revai': + return 'Optional transcriber name'; + case 'openai-compatible': + return 'Whichever model your local server exposes'; + case 'webspeech': + return ''; + } +} + +function llmModelHint(id: LLMProviderID): string { + switch (id) { + case 'anthropic': + return 'e.g. claude-sonnet-4-5 or claude-haiku-4-5-20251001'; + case 'openai': + return 'e.g. gpt-4o-mini'; + case 'gemini': + return 'e.g. gemini-2.0-flash'; + case 'mistral': + return 'e.g. mistral-large-latest'; + case 'openai-compatible': + return 'Whichever model your local server exposes'; + } +} diff --git a/styles.css b/styles.css index 71cc60f..e1076ed 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,163 @@ -/* +/* ReWrite plugin styles */ -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.rewrite-modal .rewrite-template-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} -If your plugin does not need CSS, delete this file. +.rewrite-modal .rewrite-tabs { + display: flex; + gap: 8px; + margin-bottom: 8px; +} -*/ +.rewrite-modal .rewrite-tab.is-active { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.rewrite-modal .rewrite-tab-body { + margin-top: 8px; +} + +.rewrite-modal .rewrite-recording-indicator { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.rewrite-modal .rewrite-pulse-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--color-red); + animation: rewrite-pulse 1s ease-in-out infinite; +} + +@keyframes rewrite-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.rewrite-modal .rewrite-live-transcript { + min-height: 60px; + padding: 8px; + margin-top: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + white-space: pre-wrap; +} + +.rewrite-modal .rewrite-paste { + width: 100%; + min-height: 160px; + font-family: var(--font-text); +} + +.rewrite-modal .rewrite-progress { + margin-top: 12px; + font-style: italic; +} + +.rewrite-modal .rewrite-retry { + margin-top: 12px; + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; +} + +.rewrite-setup-card { + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + margin-bottom: 12px; +} + +.rewrite-setup-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +/* Settings tab */ + +.rewrite-settings .rewrite-section-desc { + color: var(--text-muted); + font-size: var(--font-ui-small); + margin-top: -4px; + margin-bottom: 8px; +} + +.rewrite-settings .rewrite-templates-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.rewrite-settings .rewrite-templates-empty { + color: var(--text-muted); + font-style: italic; +} + +.rewrite-settings .rewrite-template-item { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 8px; + background-color: var(--background-secondary); +} + +.rewrite-settings .rewrite-template-item.is-dragging { + opacity: 0.4; +} + +.rewrite-settings .rewrite-template-item.is-drop-target { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 1px var(--interactive-accent); +} + +.rewrite-settings .rewrite-template-header { + display: flex; + align-items: center; + gap: 8px; +} + +.rewrite-settings .rewrite-drag-handle { + cursor: grab; + user-select: none; + color: var(--text-muted); + padding: 0 4px; +} + +.rewrite-settings .rewrite-drag-handle:active { + cursor: grabbing; +} + +.rewrite-settings .rewrite-template-name { + flex: 1; + font-weight: var(--font-semibold); +} + +.rewrite-settings .rewrite-template-actions { + display: flex; + gap: 4px; +} + +.rewrite-settings .rewrite-template-editor { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--background-modifier-border); +} + +.rewrite-settings .rewrite-prompt-textarea { + width: 100%; + min-height: 120px; + font-family: var(--font-text); +} + +.rewrite-settings .rewrite-templates-actions { + margin-top: 8px; +}