From 19b300c10c20c80da74ae7aca8bb19e80ed8d8c7 Mon Sep 17 00:00:00 2001 From: mpstaton Date: Sat, 2 May 2026 17:07:10 -0500 Subject: [PATCH] improve(modals): upgrade Perplexical Modal UI, context and changelogs On branch development Your branch is up to date with 'origin/development'. Changes to be committed: new file: changelog/2026-04-30_01.md new file: changelog/2026-05-01_01.md modified: changelog/2026-05-02_01.md modified: context-v/specs/Using-Files-as-Prompt-Outlines.md modified: src/modals/PerplexicaModal.ts modified: src/styles/perplexica-modal.css modified: styles.css --- changelog/2026-04-30_01.md | 481 ++++++++++++++ changelog/2026-05-01_01.md | 597 ++++++++++++++++++ changelog/2026-05-02_01.md | 17 +- .../specs/Using-Files-as-Prompt-Outlines.md | 60 +- src/modals/PerplexicaModal.ts | 253 +++++--- src/styles/perplexica-modal.css | 182 +++++- styles.css | 141 ++++- 7 files changed, 1615 insertions(+), 116 deletions(-) create mode 100644 changelog/2026-04-30_01.md create mode 100644 changelog/2026-05-01_01.md diff --git a/changelog/2026-04-30_01.md b/changelog/2026-04-30_01.md new file mode 100644 index 0000000..970e0a3 --- /dev/null +++ b/changelog/2026-04-30_01.md @@ -0,0 +1,481 @@ +--- +title: "Claude provider integration: Anthropic SDK + Ask Claude modal + two-pass citation extraction" +lede: "Claude is now a first-class research provider in Perplexed — wired via the Anthropic SDK with server-side web_search, adaptive thinking, streaming, an Ask Claude command, and a citations pipeline that handles both per-claim text-block citations and bare web_search_tool_result fallbacks." +date_work_started: 2026-04-29 +date_work_completed: 2026-04-30 +date_created: 2026-04-30 +date_modified: 2026-04-30 +publish: true +category: Changelog +tags: + - Provider-Integration + - Claude + - Anthropic-SDK + - Web-Search + - Citations + - Adaptive-Thinking + - Streaming + - Ask-Claude-Modal +authors: + - Michael Staton +augmented_with: Claude Code (Opus 4.7, 1M context) +--- + +## Why Care? + +Before this pass, Perplexed had two providers — Perplexity (cloud, +web-grounded, Sonar models) and Perplexica (local, self-hosted, +SearXNG-backed). Both work well for their respective use cases, but +neither covers the case the user actually wanted next: **server-side +web search by a frontier model with native per-claim citations**, the +pattern Anthropic ships with `web_search_20250305`. + +The motivation isn't "have one more provider." It's that Claude's +research output, when it works, is structurally different from +Perplexity's: + +- **Per-claim citations attached to text blocks**, not appended at + the end. Each sentence the model considers source-grounded gets + its own `web_search_result_location` citation with `cited_text` + (the verbatim quote) and `url`. That maps cleanly onto Lossless + Citation Spec refdefs with the `> {cited_text}` blockquote + suffix. +- **Adaptive thinking** — Claude reasons before answering on hard + questions, which produces noticeably different results from + one-shot answers on agentic / multi-source synthesis. +- **Larger context window**, which matters when piping in long + prior-conversation context or a draft to research against. + +The strategic point: Claude isn't a *replacement* for Perplexity. +It's a *different shape of research* — slower, deeper, with +citations bound to specific claims rather than appended as a +list. Adding Claude as a peer provider lets the user pick the +right tool per question type. + +The integration also surfaced a non-obvious gotcha that ate most +of the session: Anthropic ships **two** web-search tool versions +(`web_search_20250305` and the newer `web_search_20260209`), and +the newer one breaks per-claim citations as a side effect of its +dynamic-filtering pass. The older tool is the right choice for +this plugin's use case, even though it consumes more tokens. +That's documented at the call site and in `context-v/issues/` +so the next person doesn't have to rediscover it. + +## What Was Built + +### `ClaudeService` — the provider plumbing + +A new service module wrapping `@anthropic-ai/sdk`'s `Anthropic` +client. ~330 lines. Public surface: + +```ts +export interface ClaudeOptions { + enableThinking?: boolean; + enableWebSearch?: boolean; + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + maxTokens?: number; +} + +export interface ClaudeSettings { + anthropicApiKey: string; + promptsService?: PromptsService | null; + headerPosition?: 'top' | 'bottom'; +} + +export class ClaudeService { + constructor(settings: ClaudeSettings); + public updateApiKey(apiKey: string): void; + public async queryClaude( + query: string, + model: string, + stream: boolean, + editor: Editor, + options?: ClaudeOptions + ): Promise; +} +``` + +Construction-time defenses: + +- `dangerouslyAllowBrowser: true` is required because Obsidian + runs Electron with a browser-like global, and the SDK's default + refuses to instantiate without that flag. The flag's name is + the Anthropic SDK's deliberate "you should not be calling our + API from a user's browser" warning; it's appropriate here + because the Obsidian plugin runs locally on the user's machine + with the user's own key, not in a public-web context. +- API key sourced from settings, which fall back to + `process.env.ANTHROPIC_API_KEY` (loaded via `dotenv` at plugin + init). Lets the user keep keys in `.env` instead of pasting + into Obsidian settings, which is the friendlier dev workflow. +- `updateApiKey` rebuilds the client in place — no need to + re-instantiate the service when the user pastes a new key in + settings. + +### Web search tool — `web_search_20250305`, deliberately + +The non-obvious choice that drove most of this session. The call +site is annotated: + +```ts +const tools: Anthropic.Messages.ToolUnion[] = []; +if (options?.enableWebSearch !== false) { + // Use web_search_20250305 (older tool, no dynamic filtering) instead of + // web_search_20260209. The newer tool's dynamic-filtering pass post-processes + // search results in a code-execution sandbox, and per-claim + // web_search_result_location citations don't survive that round-trip — text + // blocks come back with citations: null. The 20250305 tool reliably attaches + // per-claim citations to text blocks, which is what gives us inline [N] markers + // in the rendered prose. Trade-off: more tokens consumed (no result filtering), + // but traceable per-claim citations are the whole point of this plugin. + tools.push({ type: 'web_search_20250305', name: 'web_search' }); +} +``` + +This is the kind of decision that's invisible from the API surface +and obvious-in-hindsight from observing the response shape. The +trade-off is real (more token consumption) but the alternative — +losing per-claim citations — defeats the purpose of using Claude +for research at all. + +### Two-pass citation extraction with priority merge + +Claude's response is a `content` array of typed blocks. Citations +can appear in **two** different places, and a robust extractor +has to walk both: + +```ts +private extractWebCitations(message: Anthropic.Messages.Message): ClaudeWebCitation[] { + const byUrl = new Map(); + + // Pass 1: collect raw search results as fallback — URL + title only + for (const block of message.content) { + if (block.type !== 'web_search_tool_result') continue; + const content = block.content; + if (!Array.isArray(content)) continue; // error case + for (const result of content) { + if (result.type !== 'web_search_result') continue; + if (byUrl.has(result.url)) continue; + byUrl.set(result.url, { + url: result.url, + title: result.title || 'Source', + citedText: '', + }); + } + } + + // Pass 2: overwrite with text-block citations (they carry cited_text) + for (const block of message.content) { + if (block.type !== 'text') continue; + const blockCitations = block.citations; + if (!blockCitations) continue; + for (const citation of blockCitations) { + if (citation.type !== 'web_search_result_location') continue; + byUrl.set(citation.url, { + url: citation.url, + title: citation.title ?? 'Source', + citedText: citation.cited_text ?? '', + }); + } + } + // …diagnostic console.log of block types + counts (see Open Items)… + return Array.from(byUrl.values()); +} +``` + +**Priority order is deliberate**: pass 1 collects every search +result as a URL+title-only fallback, pass 2 overwrites with the +richer per-claim text-block citations (which carry `cited_text` +— the verbatim quote needed for Lossless-spec refdefs). Dedupe +key is the URL. + +The fallback exists because Claude *sometimes* uses the search +tool but writes its answer narratively — citing sources in prose +rather than attaching formal `web_search_result_location` +citations to text blocks. Without the fallback, those answers +arrive with empty citation lists and the user sees no `### Citations` +section even though the model clearly searched. With the fallback, +the user at least gets URL + title for every source the model +consulted, even if the cited-quote field is empty. + +### Streaming via SDK + `finalMessage` + +Uses the Anthropic SDK's stream helper rather than rolling SSE +parsing by hand: + +```ts +const stream = this.client.messages.stream(params); +const currentPos = { ...responseCursor }; + +stream.on('text', (delta: string) => { + if (!delta) return; + editor.replaceRange(delta, currentPos); + // …advance currentPos by delta length / newlines… + editor.scrollIntoView({ from: currentPos, to: currentPos }, true); +}); + +const finalMessage = await stream.finalMessage(); +await this.afterMessage(finalMessage, editor, headerText); +``` + +Two design notes: + +- The `text` event fires per-delta (not per-chunk) so the cursor + advance is always over real prose, not SSE framing. The + per-chunk decode + JSON parse loop that `PerplexityService` + hand-rolls is unnecessary here because the SDK does it. +- **Non-streaming path also uses `stream + finalMessage`** rather + than `messages.create`. Reason: long responses (Opus + adaptive + thinking + web search) can take 60-90 seconds, and the SDK's + one-shot `create` call hits HTTP timeouts on those. The stream + helper keeps the connection warm and waits for completion. + From the user's perspective the UX is identical (the editor + doesn't update until the end) — the difference is just whether + the request survives. + +### Citations rendering — Lossless Citation Spec, Claude flavor + +The `### Citations` section follows the same shape Perplexity +service uses, with the verbatim quote suffix from the Lossless +spec extension: + +```ts +// Per-citation refdef — collapsed to single line, blockquote suffix optional +const collapsedCitedText = c.citedText.replace(/\s*\n\s*/g, ' ').trim(); +const citedTextSuffix = collapsedCitedText ? ` > ${collapsedCitedText}` : ''; +const safeTitle = c.title.replace(/\]/g, '\\]'); // escape ] in titles +newCitationsText += `[${citationNumber + index}]: [${safeTitle}](${c.url}).${citedTextSuffix}\n\n`; +``` + +The format: + +``` +[1]: [The Title of the Source](https://example.com/path). > The verbatim cited text from the source, on a single line. +``` + +**Why single-line refdefs**: Lossless Citation Spec requires the +refdef stay on one line so downstream parsers (cite-wide and +others) can match the standard +`[N]: [title](url). > text` shape with a single regex. Internal +newlines in cited text are collapsed to spaces. + +**Why numeric identifiers**: matches what `PerplexityService` +emits, so a vault containing both Claude and Perplexity research +notes has homogeneous citation markup. `cite-wide` can later +promote the numbers to hex codes via its substitution pass. + +**Append-vs-create logic**: if a previous query already wrote a +`### Citations` section in the same note, the next query appends +to it (with continued numbering) rather than creating a duplicate +section. The regex-driven detection finds the existing section, +parses out the highest-numbered refdef, and starts the new +citations from `max + 1`. + +### Ask Claude modal — initial version + +A dedicated modal class so users don't have to remember command +parameters. ~195 lines. Surfaces every meaningful knob: + +- **Question** — multiline textarea for the prompt. +- **Model** — dropdown over the four current Claude models + (Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5). +- **Effort** — `low / medium / high / xhigh / max`. `xhigh` and + `max` are model-bound (Opus only); the modal documents that in + the option labels. +- **Enable Web Search** — toggle. +- **Adaptive Thinking** — toggle. +- **Stream Response** — toggle (default on; recommended for long + answers). + +Submit is wired both to the **Ask Claude** button and to +**Cmd/Ctrl+Enter** in the textarea. Cancel closes without +submitting. Every option is captured into a `ClaudeOptions` object +and forwarded to `claudeService.queryClaude`. + +(Note: this initial modal lands as a functional but visually +plain UI. The wide-modal CSS unlock and the unified sectioned +layout that turns this into the polished form land the next day — +see `2026-05-01_01.md`.) + +### Settings + commands wiring (in `main.ts`) + +Two new commands registered at plugin init: + +- **`ask-claude` / "Ask Claude"** — editorCallback opens the + Ask Claude modal. Notice with actionable copy if the service + isn't initialized: *"Set ANTHROPIC_API_KEY in .env or settings, + then reinitialize services."* +- **`claude-service-status` / "Check Claude Service Status"** — + diagnostic command. Three states: initialized + key present / + initialized + no key / not initialized. + +Settings UI gets a new "Claude (Anthropic)" section with the +Anthropic API Key input. The setting is bound to the same +`anthropicApiKey` field that defaults from `.env` — so changing +it in the UI overrides the env var, which matches the +"settings-take-priority-over-env" convention the plugin already +uses for Perplexity. + +### Typed error handling at the SDK boundary + +Surfacing Anthropic SDK errors as actionable Notices instead of +raw stack traces: + +```ts +if (error instanceof Anthropic.AuthenticationError) { + userMessage = 'Invalid Anthropic API key'; +} else if (error instanceof Anthropic.RateLimitError) { + userMessage = 'Claude rate limit exceeded — wait and retry'; +} else if (error instanceof Anthropic.BadRequestError) { + userMessage = `Claude request invalid: ${error.message}`; +} else if (error instanceof Anthropic.APIError) { + userMessage = `Claude API error ${error.status}: ${error.message}`; +} else if (error instanceof Error) { + userMessage = `Claude error: ${error.message}`; +} else { + userMessage = `Claude error: ${String(error)}`; +} +``` + +Each surfaces as both a `new Notice(...)` (transient toast) and +an `**Error:** ...` line written to the editor at the cursor — +so the failure is visible whether or not the user was looking +at the toast at the moment it fired. + +### Verification + +- `pnpm run build` green after the service + modal landed and + again after the citation-extraction fallback was added. +- Claude SDK request shape confirmed against the Anthropic + Messages API reference. +- Diagnostic console logging added to `extractWebCitations` to + print block types + counts, so the user can copy that into + later sessions when triaging "why no citations" cases. + +## What Changed in Approach (the meta-lesson) + +| Pattern this rejects | Pattern this adopts | +|---|---| +| Pick the newest version of a tool ID by default (`web_search_20260209`) | Read the response-shape contract end to end before locking in a tool version. Tool versions in Anthropic's Messages API are not strictly forward-compatible — the newer web_search post-processes results in a code-execution sandbox and silently drops per-claim citations. The right version depends on what *output* you need, not which is most recent | +| Roll SSE parsing by hand for every provider | Use the SDK's stream helper when one exists (`messages.stream` + `stream.on('text', …)` + `finalMessage()`). The hand-rolled `getReader()` + `TextDecoder` + per-line JSON parse loop in `PerplexityService` exists because that provider's SDK doesn't emit text events the same way; if Anthropic's helper exists, use it and don't reinvent | +| Use `messages.create` for non-streaming UX | Use `stream + finalMessage` even when the UX is "wait then write all at once" — the streaming HTTP keeps the connection alive past the timeout window that one-shot `create` hits on long responses (Opus + thinking + web_search can run 60-90s) | +| Walk one place in the response for citations | Walk both places: the formal text-block `web_search_result_location` citations *and* the raw `web_search_tool_result` blocks. Priority-merge by URL with the richer source winning. The fallback covers the failure mode where Claude searches but doesn't formally cite, which happens often enough to matter | +| Treat `dangerouslyAllowBrowser: true` as a smell | Read the actual constraint: the flag name is Anthropic's warning to public-web apps, not to Obsidian-plugin contexts. The Obsidian plugin runs locally on the user's machine with the user's own key. Setting the flag is the right choice; the warning doesn't apply | +| Hardcode the API key into the service | Read from settings; settings default from `process.env.ANTHROPIC_API_KEY` via `dotenv`. Lets the user pick the workflow that fits — UI for casual setup, `.env` for dev — and the env var doesn't leak into UI state if the user later prefers settings | +| Surface SDK errors as raw stack traces | Type-narrow against `Anthropic.AuthenticationError`, `RateLimitError`, `BadRequestError`, `APIError` and emit actionable Notices for each. The user sees "Invalid Anthropic API key," not a 12-line stack | + +The generalizable point: **a provider integration is not done when +the service compiles.** It's done when the response shape is +actually being read end-to-end and the failure modes (no +citations, timed-out long requests, invalid key, rate-limit) are +each traced to a user-visible signal. Half of this session was +the failure-mode coverage. + +## Open Items + +- **Tool-choice is `auto`, not `any`.** The model decides whether + to actually invoke `web_search`. With Opus 4.7's January 2026 + training cutoff, recent factual questions sometimes get + answered from training without a search call — and no search + call means no `web_search_tool_result` blocks and no citations, + even if the answer is correct. Two possible fixes, neither + shipped this pass: + - `tool_choice: { type: 'any' }` — forces at least one tool + call. Since `web_search` is the only tool, this forces a + search every time. + - System prompt: *"ALWAYS use the web_search tool to verify and + ground every factual claim. Do not answer from training + knowledge."* + Held until the diagnostic logs show how often this actually + happens in practice. Documented in + `context-v/issues/Getting-Claude-to-Respond-With-Research.md`. +- **Diagnostic logging is console-only.** The + `[ClaudeService] extractWebCitations — block types: [...]; text blocks with citations: N; extracted: N` + line goes to Obsidian's dev console (`Cmd+Opt+I`). Users who + hit the "no citations" failure mode have to know to open the + console. Either move it to a Notice when extracted count is 0, + or surface a "Why no citations?" inline note in the rendered + output. Held. +- **Other parseable response data is not used.** The response + contains `server_tool_use` blocks (the actual query string + Claude searched for), `code_execution_tool_result` blocks + (output of the dynamic-filter pass on the newer tool — not + applicable here), `page_age` per result. All currently + ignored. Could be surfaced in the rendered output: "Claude + searched for: …" preamble, date-aware refdefs using + `page_age`. Held — scope creep on this pass. +- **Effort `xhigh` / `max` are model-bound but not validated.** + The modal's effort dropdown lists `xhigh (Opus 4.7)` and + `max (Opus only, highest cost)` in the labels, but pairing + Haiku with `max` doesn't fail until request time. Could + dynamically filter the effort options based on the model + selection. Held. +- **Modal is functional but visually plain.** This first version + uses Obsidian's narrow default modal width and a stack-of- + Settings layout. The unified sectioned design with the + wide-modal CSS unlock lands the next day; tracked in + `2026-05-01_01.md`. +- **No retry logic for rate limits.** A `RateLimitError` surfaces + a Notice and an editor error line, but doesn't auto-retry with + backoff. For research workflows where the user hit limit + during a streaming response, manual retry is fine for now. + Held. + +## Files Touched + +``` +perplexed/ +├── package.json (added @anthropic-ai/sdk to dependencies) +├── main.ts (Settings: claudeDefaultModel, anthropicApiKey defaulting from process.env; service init at plugin onload, re-init on settings change; registerClaudeCommands wiring 'ask-claude' + 'claude-service-status' commands; settings UI Claude section with API key input) +├── src/ +│ ├── services/ +│ │ └── claudeService.ts (created — ~330 lines; ClaudeService class wrapping @anthropic-ai/sdk; queryClaude with stream / non-stream paths both via messages.stream + finalMessage; extractWebCitations two-pass priority-merge; addCitations writing Lossless-spec refdefs with cited_text blockquote suffix; typed error handling for Anthropic SDK error classes) +│ └── modals/ +│ └── ClaudeModal.ts (created — ~195 lines; functional but visually plain initial version: model dropdown, effort dropdown, web-search/thinking/stream toggles, textarea with Cmd/Ctrl+Enter submit; redesigned the next day per 2026-05-01_01.md) +└── context-v/ + └── issues/ + └── Getting-Claude-to-Respond-With-Research.md (created — diagnosis of the two failure modes for missing citations: tool_choice gives Claude discretion to skip search; or Claude searches but emits only web_search_tool_result without per-claim text-block citations. Includes the diagnostic-logging recipe, the tool_choice: any vs system-prompt fix options, and notes on other parseable response data — server_tool_use, page_age, code_execution_tool_result — that could be surfaced) +``` + +## Reference + +- **The web-search tool decision:** + `src/services/claudeService.ts:79-90` — the inline comment is + the canonical record of why `web_search_20250305` is the right + choice for this plugin even though `web_search_20260209` is + newer. +- **The two-pass extractor:** + `src/services/claudeService.ts:204-249` — `extractWebCitations` + with the priority-merge logic and diagnostic logging. +- **Citation rendering:** + `src/services/claudeService.ts:257-305` — `addCitations` + emitting Lossless-spec refdefs with cited-text blockquote + suffix and append-to-existing logic. +- **The struggle, in detail:** + `context-v/issues/Getting-Claude-to-Respond-With-Research.md` + — written during this pass; the two failure modes, the + diagnostic logging recipe, the proposed `tool_choice: any` / + system-prompt fixes, and the broader catalog of parseable + response data that's currently ignored. +- **Commands:** `main.ts:682-712` — `registerClaudeCommands` for + `ask-claude` and `claude-service-status`. +- **Settings UI:** `main.ts:1107+` — Claude (Anthropic) section + with the API key input; defaults to `process.env.ANTHROPIC_API_KEY`. +- **Anthropic SDK reference:** + `@anthropic-ai/sdk` v0.92.0 (the version pinned in + `package.json`); `Anthropic.Messages.MessageStreamParams`, + `messages.stream()`, `stream.on('text', …)`, + `stream.finalMessage()`. +- **Lossless Citation Spec — Claude extension:** the + `[N]: [title](url). > {cited_text}` shape that + `addCitations` emits. `cite-wide` can later promote numeric + identifiers to hex codes via its substitution pass; the + numeric form here is intentional for cross-provider homogeneity + with `PerplexityService`. +- **Successor changelog:** `2026-05-01_01.md` — modal redesign + pass that brings Ask Claude (and Ask Perplexity, Ask Perplexica) + to the unified sectioned layout with the wide-modal CSS unlock. +- **Recent commits on `development` (this pass):** + `9d5bc80 progress(model-provider): attempt to add new model, claude. Struggling with getting returned research objects.` (initial ClaudeService + ClaudeModal + main.ts wiring), + `b5a70c7 stuck(claude): claude not returning research citations or reference definitions, just plain text` (diagnosing the missing-citations failure mode), + `d7551d7 stuck(model): stuck trying to get model Claude API working` (refinements to claudeService + extractor fallback). diff --git a/changelog/2026-05-01_01.md b/changelog/2026-05-01_01.md new file mode 100644 index 0000000..e1391fd --- /dev/null +++ b/changelog/2026-05-01_01.md @@ -0,0 +1,597 @@ +--- +title: "UX pass: modal redesign + wide-modal CSS unlock" +lede: "The three 'Ask ' modals — Ask Perplexity, Ask Claude, and Ask Perplexica — all have beautiful, wide, and consistent Modal UIs now." +date_work_started: 2026-05-01 +date_work_completed: 2026-05-02 +date_created: 2026-05-02 +date_modified: 2026-05-02 +publish: true +category: Changelog +tags: + - UX-Pass + - Modal-Redesign + - Ask-Perplexity + - Ask-Claude + - Ask-Perplexica + - Obsidian-Modal-CSS + - BEM + - Wide-Modal-Unlock +authors: + - Michael Staton +augmented_with: Claude Code (Opus 4.7, 1M context) +--- + +## Why Care? + +Before this pass, the three `Ask ` modals were three different +shapes. `Ask Perplexity` was a plain stack of `.setting-item` divs with +hand-rolled labels; `Ask Perplexica` was a stub form with one CSS rule +(`.text-input { width: 100%; padding: 12px; }`); `Ask Claude` did not +exist yet — Claude integration was being designed in this session and +needed a UI to expose its model / effort / web-search / thinking knobs. + +Three problems compounded: + +1. **No shared visual language.** Each modal had its own ad-hoc + structure. A user toggling between providers got a different feel + each time, with controls in different positions. +2. **Modals stayed narrow regardless of CSS.** Every plugin in the + tree had inherited the same convention from the Obsidian sample + plugin — `contentEl.addClass('my-modal')` — and every plugin's + `width: 90vw; max-width: 800px;` rule on that class was a no-op. + The outer `.modal` element kept Obsidian's default constraints and + the inner `.modal-content` shrank to fit. So even the modals that + *tried* to widen looked indistinguishable from the bare default. +3. **Options without explanations.** Dropdowns listed values + (`webSearch`, `academicSearch`, `wolframAlpha`, `sonar-deep-research`) + with no live description of what each option actually does. A user + had to consult external docs — or just guess. + +The maintenance pass on `2026-05-02_01.md` (deps refresh + streaming +citations bug) cleared the toolchain. This pass is the user-facing +companion: with the toolchain green, redesign the three primary entry +points so the plugin reads as one coherent product rather than three +generations of UI archaeology. + +The strategic point: the redesign is not "make it pretty." It's "make +the cost of choosing the right options visible." Each modal now +surfaces, beside every dropdown, a **tagline** that updates live as the +user toggles. The user no longer needs to know what `redditSearch` +focus mode does — the tagline tells them. The CSS unlock is what made +the room for those taglines viable; without the wider modal the +description column gets crushed. + +## What Was Built + +The three 'Ask ' modals — Ask Perplexity, Ask Claude, and Ask Perplexica — were brought to a shared sectioned layout: header / question / search / returns / behavior / footer, BEM-scoped class names, native Obsidian Setting components for dropdowns and toggles, live taglines under each dropdown that explain what each option actually does, and Cmd/Ctrl+Enter submit. + +The unlocking discovery was structural rather than visual: Obsidian Modal width is set on the OUTER `.modal` element (`this.modalEl`), not the INNER `.modal-content` (`this.contentEl`), so for years the convention `contentEl.addClass(...)` had been silently neutering every plugin's `width: 90vw` / `max-width` rules. + +Fixing that single line let all three modals widen to a readable 640px form, which in turn made the sectioned layout viable. The unlock is documented in detail in `context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md`." + +### The wide-modal CSS unlock + +Discovered while building `Ask Claude` — the modal kept rendering at +the Obsidian-default narrow width even with the canonical CSS: + +```css +.claude-modal { + width: 90vw; + max-width: 640px; +} +``` + +The rule parsed, the rule applied, the modal stayed narrow. Diagnosis: +the long-standing plugin convention of attaching the class to +`contentEl` only sizes the **inner** `.modal-content` element, not the +**outer** `.modal` container that Obsidian's stock CSS actually +constrains. The fix is a single line: + +```ts +// Inside Modal.onOpen() +modalEl.addClass('claude-modal'); // ← outer element +// not: +// contentEl.addClass('claude-modal'); // ← inner content area only +``` + +Once the class is on `modalEl`, the same width rule actually widens +the popup. The complementary CSS that makes the rest of the layout +viable: + +```css +.claude-modal { + width: 90vw; + max-width: 640px; +} + +/* Zero Obsidian's default ~20px padding so our own sections can + own their padding without fighting the parent. */ +.claude-modal .modal-content { + padding: 0; +} +``` + +This pattern — `modalEl.addClass(...)` + width on outer + zeroed +inner padding — is now the baseline for every modal in this plugin +and is documented in full at +`context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md` (with the +counter-examples in this same repo that demonstrated the failure +mode: `text-enhancement-modal.css` sets `max-width: 800px` correctly +but renders narrow because its `.ts` file uses `contentEl.addClass`). + +### The shared sectioned layout + +With width unlocked, all three modals adopted the same structural +skeleton: + +``` +┌───────────────────────────────────────────┐ +│ Header — title + subtitle │ +├───────────────────────────────────────────┤ +│ Question — full-width textarea │ +├───────────────────────────────────────────┤ +│ Search / Model — dropdowns w/ taglines │ +├───────────────────────────────────────────┤ +│ Returns — toggles for what to include │ +├───────────────────────────────────────────┤ +│ Behavior — Stream toggle, etc. │ +├───────────────────────────────────────────┤ +│ Footer — Cancel + primary CTA (right) │ +└───────────────────────────────────────────┘ +``` + +BEM-scoped class names (`.-modal__header`, +`__section`, `__section-title`, `__label`, `__textarea`, +`__footer`, `__button`) keep selector specificity flat and +unambiguous so a future child element with `class="header"` from +some third-party widget cannot accidentally pick up modal styles. + +The header/section/footer chrome: + +```css +.claude-modal__header { + padding: 24px 28px 16px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.claude-modal__section { + padding: 18px 28px 4px; +} + +.claude-modal__section + .claude-modal__section { + border-top: 1px solid var(--background-modifier-border-hover); + margin-top: 4px; +} + +.claude-modal__section-title { + margin: 0 0 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.claude-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); +} +``` + +Two design choices worth flagging: + +- **Hairline borders, not boxes.** `--background-modifier-border` / + `--background-modifier-border-hover` give visual hierarchy without + the heaviness of card-style boxes, and the tokens mean every + Obsidian theme — light, dark, community — gets correct contrast + for free. +- **Footer with `--background-secondary` background.** The subtle + tint on the action tray reads as "this is where you commit," which + matches every native macOS / Windows convention for primary + actions and is what the user's hand expects. + +The textarea pattern (used identically in all three modals): + +```css +.claude-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; +} + +.claude-modal__textarea:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 3px var(--interactive-accent-hover); +} +``` + +The 3px box-shadow halo using the **hover** shade of the accent (not +the saturated accent itself) is the trick that makes focus state read +as modern — softer than a hard accent ring, more legible than no +indicator at all. + +The button pattern, opting into Obsidian's `.mod-cta` convention so +the primary action responds to theme tweaks like a native button: + +```css +.claude-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; +} + +.claude-modal__button:hover { background-color: var(--background-modifier-hover); } +.claude-modal__button:active { transform: translateY(1px); } +.claude-modal__button:disabled { opacity: 0.5; cursor: not-allowed; } + +.claude-modal__button.mod-cta { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +.claude-modal__button.mod-cta:hover { + background-color: var(--interactive-accent-hover); + border-color: var(--interactive-accent-hover); +} +``` + +The `transform: translateY(1px)` on `:active` is one line; it makes +buttons feel physically clickable; the cost is zero. + +Native Obsidian `Setting` rows for dropdowns and toggles are tightened +to fit the custom layout instead of fighting it: + +```css +.claude-modal__section .setting-item { + padding: 10px 0; + border-top: none; +} + +.claude-modal__section .setting-item + .setting-item { + border-top: 1px solid var(--background-modifier-border-hover); +} +``` + +Mobile breakpoint at 600px (single value, applied identically across +all three modals): + +```css +@media (max-width: 600px) { + .claude-modal { + width: 95vw; + max-width: none; /* explicitly clear the cap */ + } + + .claude-modal__header, + .claude-modal__section, + .claude-modal__footer { + padding-left: 18px; + padding-right: 18px; + } + + .claude-modal__footer { + flex-direction: column-reverse; /* CTA on top, thumb-reach */ + } + + .claude-modal__button { + width: 100%; + } +} +``` + +`max-width: none` (rather than `max-width: 95vw`) is deliberate: +without it, the inherited 640px cap silently wins over the new +`width: 95vw` at 700px viewports and you get an awkward gap. + +### Ask Perplexity — full redesign + +The previous Perplexity modal was 157 lines of inline-styled form +construction with hand-rolled labels and no native Setting integration. +The new modal is restructured into the sectioned skeleton above with: + +- **Header**: "Ask Perplexity" + subtitle "Web-grounded research with + native citations. Streams into the active note at the cursor." +- **Question** section: full-width textarea with placeholder from + `promptsService.getPerplexityQueryPlaceholder()`, Cmd/Ctrl+Enter + submits. +- **Model** section: model dropdown (`sonar-pro`, `sonar-small`, + `sonar-deep-research`, llama variants) with **live tagline** under + the control. Selecting `sonar-deep-research` swaps in the long-form + description from `promptsService.getDeepResearchDescription()` so + the user sees the "30-60 second wait, exhaustive multi-source" + caveat at decision time, not after submit. A separate "Recency + Filter" dropdown lists day / week / month / year and three + multi-year options that fall back to "year" (the API ceiling — the + fallback is documented in the description). +- **Include in response** section: three toggles with descriptions — + Citations ("Append a Citations section…"), Images, Related + Questions ("Surface follow-up questions Perplexity suggests…"). +- **Behavior** section: Stream Response toggle with the + deep-research-aware description ("Deep Research model can take + 30-60s — streaming makes progress visible"). +- **Footer**: Cancel + Ask Perplexity CTA, right-aligned. + +`modalEl.addClass('perplexity-modal')` — the wide-modal unlock +applied; the file carries an inline comment explaining why +`contentEl.addClass(...)` would not have worked. The `.css` file grew +from ~26 lines to 184 lines covering the full BEM stylesheet. + +### Ask Claude — newly built + +The Claude integration needed a UI; this is what it got. Same +sectioned skeleton, different control set: + +- **Header**: "Ask Claude" + subtitle "Server-side web search with + native citations. Streams into the active note at the cursor." +- **Question** section: full-width textarea, Cmd/Ctrl+Enter submits. +- **Model** section: model dropdown listing the four current Claude + models with live taglines — + - `claude-opus-4-7` → "Most capable — research / agentic / vision" + - `claude-opus-4-6` → "Previous-generation Opus" + - `claude-sonnet-4-6` → "Best speed / intelligence balance" + - `claude-haiku-4-5` → "Fastest, lowest cost" + Effort dropdown (`low` / `medium` / `high` / `xhigh` / `max`) with + a description that calls out which efforts are model-bound (`xhigh` + = "deep agentic (Opus 4.7)", `max` = "Opus only, highest cost"). +- **Behavior** section: three toggles — + - **Enable Web Search** — surfaces the + `web_search_20250305` server-side tool, with a description that + mentions per-claim citations on text blocks (so the user knows + why their answer will arrive with structured citation data, not + inline links). + - **Adaptive Thinking** — extended reasoning toggle with the + latency / token tradeoff in the description. + - **Stream Response** — recommended for long answers; the + description explains *why* (avoids HTTP timeouts, writes + incrementally to the note). +- **Footer**: Cancel + Ask Claude CTA, right-aligned. + +The Claude modal was the **first** modal to use `modalEl.addClass` +in this plugin — it's where the wide-modal unlock was discovered. +Everything else inherits from this one. + +### Ask Perplexica — upgraded from stub to peer + +The previous Perplexica modal was minimal: hand-rolled dropdowns +without native Setting integration, a one-line CSS file, and dropdown +options listed as raw values (`webSearch`, `academicSearch`, +`wolframAlpha`) with no descriptions of what they do. The redesign +brings it into the same shape as Perplexity and Claude: + +- **Header**: "Ask Perplexica" + subtitle "Self-hosted, + source-grounded answers via your local Perplexica instance. + Streams into the active note at the cursor." +- **Question** section: full-width textarea with placeholder from + `promptsService.getPerplexicaQueryPlaceholder()`, Cmd/Ctrl+Enter + submits. +- **Search** section — the Perplexica-specific knobs that previously + had no explanation now each carry a live tagline: + - **Focus Mode** dropdown — six modes with taglines that explain + what each one actually does: + - Web Search → "General web search via SearXNG — broad coverage, + default choice." + - Academic Search → "Scholarly papers from arXiv, Google Scholar, + PubMed-style sources." + - Writing Assistant → "No web search — pure writing help + (drafting, rewriting, summarizing)." + - Wolfram Alpha → "Computational / factual queries — math, + units, structured data." + - YouTube Search → "Video results with transcripts when + available." + - Reddit Search → "Discussion threads — opinions, anecdotes, + community knowledge." + - **Optimization** dropdown — three modes with the speed / depth + tradeoff explicit: + - Speed → "Fewest sources, quickest answer. Good for simple + questions." + - Balanced → "Default — reasonable depth without long waits." + - Quality → "More sources and deeper synthesis. Slower but + more thorough." +- **Include in response** section: Images toggle. +- **Behavior** section: Stream Response toggle with description + oriented to the local-model use case ("useful for long answers + and slow local models"). +- **Footer**: Cancel + Ask Perplexica CTA, right-aligned. + +Live taglines hold the public reference to each Setting's +description element via `Setting.descEl` rather than fragile CSS +selectors: + +```ts +const focusSetting = new Setting(searchSection) + .setName('Focus Mode') + .setDesc(this.focusTagline(this.focusMode)) + .addDropdown(dd => { + FOCUS_MODES.forEach(({ value, label }) => dd.addOption(value, label)); + dd.setValue(this.focusMode); + dd.onChange((value) => { + this.focusMode = value; + if (this.focusDescEl) this.focusDescEl.textContent = this.focusTagline(value); + }); + }); +this.focusDescEl = focusSetting.descEl; +``` + +`Setting.descEl` is a public property on the Obsidian `Setting` +class. Using it directly is more robust than +`section.querySelector('.setting-item:last-of-type +.setting-item-description')` — the selector approach breaks if +section structure changes (e.g., another `

` is added between +Settings). + +The Perplexica `.css` file grew from 6 lines to 161 lines, mirroring +the `claude-modal.css` shape so the three modals are visually +consistent. + +### Verification + +- `pnpm run build` (production: `tsc -noEmit -skipLibCheck && node + esbuild.config.mjs production`) green after each modal redesign + and after the final Perplexica pass. Zero ESLint errors, zero + TypeScript errors on the touched files. +- All three modals visually verified in Obsidian's renderer at the + intended 640px max width. +- Cmd/Ctrl+Enter submit verified on each modal's textarea. +- Theme parity verified by toggling between Obsidian's light and + dark themes — every color resolves through CSS custom properties + (`var(--text-normal)`, `var(--background-primary)`, + `var(--interactive-accent)`, etc.) so no hardcoded `#fff` / + `#000` exists anywhere in the new stylesheets. + +## What Changed in Approach (the meta-lesson) + +| Pattern this rejects | Pattern this adopts | +|---|---| +| Attach the modal class to `contentEl` because that's what the Obsidian sample plugin does | Attach to `modalEl` (the outer `.modal` element) so width / max-width rules actually apply to the popup; zero `.modal-content` padding so your own sections own their spacing | +| Hand-roll labels + form-style divs for dropdowns and toggles | Use Obsidian's native `Setting` components for dropdowns / toggles — automatic theme parity, native interaction patterns, accessibility for free; reach for raw HTML only where Setting is a poor fit (e.g., a 6-row textarea, where Setting's right-edge control placement is wrong) | +| List dropdown values without explaining what each one does | Surface a **live tagline** under every dropdown that updates on change — the user reads the consequence of the choice at decision time, not after submit. Hold the description element via `Setting.descEl` (public API) rather than CSS selectors that drift with structure | +| Use nested CSS selectors that drift (`.my-modal .header`, `.my-modal .section`) | Use BEM-flat selectors (`.my-modal__header`, `.my-modal__section`) so a stray `class="header"` from a third-party widget can't accidentally inherit modal styles | +| Hardcode colors (`#fff`, `#333`) and font sizes | Reach for Obsidian's CSS custom properties (`var(--text-normal)`, `var(--font-text)`, `var(--background-modifier-border)`) so the modal is theme-portable across light, dark, and community themes for free | +| Three modals, three structural shapes ("each provider gets its own UI") | One sectioned skeleton — header / question / search / returns / behavior / footer — applied identically to all three providers; the differences are in *which controls each section contains*, not in the layout itself | +| Treat "the modal stays narrow" as an Obsidian limitation | Treat unexpected styling behavior as a clue that the CSS is targeting the wrong DOM node; verify with the inspector before concluding the framework is at fault | + +The generalizable point: **the unlock was structural, not aesthetic.** +The width problem looked like a CSS taste question ("Obsidian modals +just look narrow") and was actually a DOM-targeting question ("the +class is on the wrong element"). Once that single line shifted from +`contentEl.addClass` to `modalEl.addClass`, the room for everything +else — sectioned layout, live taglines, breathing footer — appeared. +The CSS files in this pass would have been written essentially the +same way against either DOM target, but only one of those targets +makes the result visible to the user. + +## Open Items + +- **`text-enhancement-modal` and `text-enhancement-with-images-modal` + are still on the `contentEl.addClass` pattern.** The `.css` files + set `max-width: 800px` correctly but the rule is silently ignored. + These modals would benefit from the same conversion: change the + one `.ts` line to `modalEl.addClass(...)`, zero `.modal-content` + padding, and they widen for free with no other changes. Held — + separate intent from this pass, and the call sites are different + enough (these modals are content-paste workflows, not Q&A) that a + light layout audit should accompany the CSS conversion. +- **`url-update-modal`, `article-generator-modal`, and + `lmstudio-modal`** likewise. Same fix pattern; same scoping + reason. +- **No automated visual-regression tests.** All verification was + manual via the Obsidian renderer. The plugin has no Playwright + / Storybook setup; the BEM scoping makes it easy to add later + (each modal class is independently snapshot-able), but adding a + test runner is its own project. +- **Tagline copy is not externalized.** The focus-mode and + optimization taglines for Perplexica live as inline literals in + `PerplexicaModal.ts`. Other modals route their copy through + `promptsService` (e.g., `getPerplexityQueryPlaceholder()`, + `getDeepResearchDescription()`). The Perplexica taglines are + small enough that inlining is fine for now, but if the user wants + to localize or A/B-test descriptions, they should be moved to + `promptsService` for consistency with the other modals. +- **Effort `xhigh` and `max` model-binding is a description-only + hint.** The Claude modal's Effort dropdown lists `xhigh` (Opus 4.7) + and `max` (Opus only) without enforcing model compatibility — a + user could pair Haiku with `max` and discover the mismatch at + request time. Validation could be added at submit, or the dropdown + could disable model-incompatible options when the model dropdown + changes; held for a UX follow-up. +- **No keyboard shortcut beyond Cmd/Ctrl+Enter.** Tab order works + but there's no `Esc → Cancel` wired explicitly (the default modal + Esc behavior closes, which is fine but not documented in the + modal). A small affordance like a footer hint ("⌘↵ to send · Esc + to cancel") could be added — held. +- **Commit not created.** The Perplexica pass is staged in working + tree only (`src/modals/PerplexicaModal.ts`, + `src/styles/perplexica-modal.css`, `styles.css` rebuilt). The + Claude and Perplexity passes were committed earlier + (`9d5bc80 progress(model-provider)`, + `d7551d7 stuck(model)`, + `46e4f24 align(requirements)`). + +## Files Touched + +``` +perplexed/ +├── src/ +│ ├── modals/ +│ │ ├── ClaudeModal.ts (created — 195 lines, sets the wide-modal unlock pattern; modalEl.addClass; sectioned skeleton; CLAUDE_MODELS + EFFORT_OPTIONS const tables; live model tagline; web-search / thinking / stream toggles; Cmd/Ctrl+Enter submit) +│ │ ├── PerplexityModal.ts (rewritten — 157 → 243 lines; modalEl.addClass with inline comment explaining why; PERPLEXITY_MODELS + RECENCY_OPTIONS const tables; live model tagline that swaps to long-form description for sonar-deep-research; citations / images / related-questions toggles; Cmd/Ctrl+Enter submit) +│ │ └── PerplexicaModal.ts (rewritten — 128 → 209 lines; modalEl.addClass; FOCUS_MODES + OPTIMIZATION_MODES const tables with taglines; live taglines via Setting.descEl; images / stream toggles; Cmd/Ctrl+Enter submit) +│ └── styles/ +│ ├── claude-modal.css (created — 179 lines; full BEM stylesheet establishing the pattern; header / sections / footer chrome; textarea with focus halo; mod-cta button; mobile breakpoint at 600px) +│ ├── perplexity-modal.css (rewritten — ~26 → 184 lines; mirrors claude-modal.css shape with perplexity- prefix; same width / padding / chrome / button conventions) +│ ├── perplexica-modal.css (rewritten — 6 → 161 lines; mirrors claude-modal.css shape with perplexica- prefix; same width / padding / chrome / button conventions) +│ └── main.css (already imported all three modal stylesheets — no plumbing change required) +└── context-v/ + └── issues/ + └── Widen-Modals-in-Obsidian-using-CSS.md (full write-up of the contentEl-vs-modalEl unlock, with copy-paste starter CSS, design-token table, common gotchas, and counter-examples in this same repo) +``` + +`main.js` and `styles.css` are build artifacts; rebuilt by +`pnpm run build` and not part of the source-of-truth diff. + +## Reference + +- **Predecessor changelog:** `2026-05-02_01.md` — toolchain + maintenance pass (deps refresh + streaming citations bug fix). + This pass picks up from that baseline; the redesigns assume the + toolchain is current and the streaming pipeline returns correct + citation metadata. +- **The wide-modal write-up:** + `context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md` — + TL;DR, DOM-element distinction, why the convention fails, full + fix with copy-paste starter CSS + matching `Modal` skeleton, theme + token table, common gotchas, verification receipts pointing at + the working / counter-example modals in this repo. +- **Pattern source modal:** `src/modals/ClaudeModal.ts` + + `src/styles/claude-modal.css` are the reference implementation; + the other two follow this shape. +- **Obsidian Modal API:** `Modal.modalEl` and `Modal.contentEl` are + both public; this pass relies on the distinction. `Setting.descEl` + is also public and is what the Perplexica modal uses to live-update + taglines. +- **CSS custom properties used:** `--text-normal`, `--text-muted`, + `--text-faint`, `--text-on-accent`, `--background-primary`, + `--background-secondary`, `--background-modifier-border`, + `--background-modifier-border-hover`, + `--background-modifier-hover`, `--interactive-accent`, + `--interactive-accent-hover`, `--font-text`, `--modal-radius`. + All Obsidian-native tokens; theme-portable across light / dark / + community themes. +- **Counter-example modals (still on the old pattern):** + `text-enhancement-modal.css`, `text-enhancement-with-images-modal.css`, + `url-update-modal.css`, `article-generator-modal.css`, + `lmstudio-modal.css` — all set `width` / `max-width` on classes + attached via `contentEl.addClass(...)` and therefore render at + the Obsidian default narrow width. Conversion to the new pattern + is a one-line `.ts` change + zeroed `.modal-content` padding; + held for a follow-up pass. +- **Recent commits on `development` (this pass):** + `9d5bc80 progress(model-provider): attempt to add new model, claude` (Claude modal landed), + `d7551d7 stuck(model): stuck trying to get model Claude API working` (Perplexity modal redesign + first pass at the wide-modal unlock applied to both), + `46e4f24 align(requirements): align Perplexed to Obsidian's plugin community standards` (tidy alignment of both modals), + Perplexica redesign — staged in working tree only. diff --git a/changelog/2026-05-02_01.md b/changelog/2026-05-02_01.md index f1d0e33..86cdecd 100644 --- a/changelog/2026-05-02_01.md +++ b/changelog/2026-05-02_01.md @@ -1,12 +1,11 @@ --- -title: "Maintenance pass — Dependency refresh (incl. ESLint v10 + TypeScript 6) + streaming-citations bug fix" -lede: "First changelog for this plugin. Two coordinated tracks: (1) refresh every dependency to current latest, including the two majors that required config migration — ESLint 9 → 10 (flat config) and TypeScript 5 → 6 (deprecated tsconfig options); (2) fix a streaming-response bug where a misnamed 'stale data' cleanup was wiping captured Perplexity citations on the final SSE chunk, so the rendered note never got a Citations reference section. Build verified clean (tsc + esbuild production). One known-flaky behavior — streaming responses occasionally truncate mid-answer with no error in the console — is documented under Open Items but not diagnosed; the user explicitly deferred ('something is funky I don't want to diagnose, or it's an api or credit thing')." -date_authored_initial_draft: 2026-05-02 -date_authored_current_draft: 2026-05-02 -date_first_published: 2026-05-02 -date_last_updated: 2026-05-02 -at_semantic_version: 0.0.0.1 -status: Published +title: "Maintenance pass: dependency refresh + streaming-citations bug fix" +lede: "We are maintained. Updated all packages, solved for dependabot flags, and fixed a streaming-citations bug." +date_work_started: 2026-05-02 +date_work_completed: 2026-05-02 +date_created: 2026-05-02 +date_modified: 2026-05-02 +publish: true category: Changelog tags: - Maintenance @@ -57,6 +56,8 @@ ## Why Care? ## What Was Built +Two coordinated tracks: (1) refresh every dependency to current latest, including the two majors that required config migration — ESLint 9 → 10 (flat config) and TypeScript 5 → 6 (deprecated tsconfig options); (2) fix a streaming-response bug where a misnamed 'stale data' cleanup was wiping captured Perplexity citations on the final SSE chunk, so the rendered note never got a Citations reference section. Build verified clean (tsc + esbuild production). One known-flaky behavior — streaming responses occasionally truncate mid-answer with no error in the console — is documented under Open Items but not diagnosed; the user explicitly deferred ('something is funky I don't want to diagnose, or it's an api or credit thing')." + ### Dependency refresh (Track 1) | Package | Was | Now | Major? | diff --git a/context-v/specs/Using-Files-as-Prompt-Outlines.md b/context-v/specs/Using-Files-as-Prompt-Outlines.md index cb2f6e1..747c4dd 100644 --- a/context-v/specs/Using-Files-as-Prompt-Outlines.md +++ b/context-v/specs/Using-Files-as-Prompt-Outlines.md @@ -1,14 +1,52 @@ -On the Outlines folder feature — yes, totally feasible and a great unlock. Sketch: - - New plugin setting: outlinesFolderPath (default Outlines-Perplexed). - - Each .md file in that folder = one template. Frontmatter declares name, description, provider - (perplexity | claude | both), optional model, optional effort. Body is the prompt with {{TERM}} / - {{TOPIC}} / {{QUERY}} placeholders. - - New command: "Generate from Outline…" opens a modal with: outline picker (dropdown of files in - the folder), term/topic input, provider/model overrides, submit. - - ArticleGeneratorModal becomes one built-in outline (deep-research one-pager) — same flow, no + +# Using Files as Prompt Outlines + +## Development Goals + +- New command: "Generate Prompt Outline from Template" +- New command: "Generate Prompt from Outline" opens a modal with: outline picker +- ArticleGeneratorModal becomes one built-in outline (deep-research one-pager) — same flow, no special-casing. - This decouples your prompt library from the codebase entirely. Add a new template = drop a .md in - the folder, no rebuild needed. It ALSO means when Claude does work, you can author Claude-specific - outlines without touching code. \ No newline at end of file +## User Goals + +An Obsidian user can create a folder that will host prompt outlines as markdown files. + +## Sketch + - New plugin settings + - `contentDevFolderPath` (default `Content-Dev`) // nested folder for content development + - `outlinesFolderPath` (default `Content-Dev/Outlines`) + - `templatesFolderPath` (default `Content-Dev/Templates`) + - Each .md file in that folder = one outline. Frontmatter declares + ```yaml + title: String + main_inquriy: String + description: String + providers_models: Array // ("perplexity: {model}" | "perplexica: {model}" | "claude: {model}") + ``` + Body is the prompt with optional preferred structure: + +```markdown + # Example Content + {Obsidian backlink syntax to other files, optional} + + # Background + + {paragraph context eg background info, goals, user situation, model role eg analyst, academic, marketer, scientist, newsroom, columnist, optional} + + # Section Outline for Response + + {## Section Headers} // optional // must be level 2 headers or greater. No Level 1 headers, as it will mess with the LLM's ability to nest/parse the outline for the response with other elements. + + {ul or li list guiding questions, optional} + + {preferences or spectations for response eg dos and donts, optional} + + {preferences on research sources, include exclude ul, optional} +``` + + +This decouples your prompt library from the codebase entirely. Add a new template = drop a .md in +the folder, no rebuild needed. It ALSO means when Claude does work, you can author Claude-specific +outlines without touching code. \ No newline at end of file diff --git a/src/modals/PerplexicaModal.ts b/src/modals/PerplexicaModal.ts index 7246623..2775391 100644 --- a/src/modals/PerplexicaModal.ts +++ b/src/modals/PerplexicaModal.ts @@ -1,128 +1,203 @@ import type { App, Editor } from 'obsidian'; -import { Modal, Notice } from 'obsidian'; +import { Modal, Notice, Setting } from 'obsidian'; import type { PerplexicaService, PerplexicaOptions } from '../services/perplexicaService'; import type { PromptsService } from '../services/promptsService'; +const FOCUS_MODES: Array<{ value: string; label: string; tagline: string }> = [ + { value: 'webSearch', label: 'Web Search', tagline: 'General web search via SearXNG — broad coverage, default choice.' }, + { value: 'academicSearch', label: 'Academic Search', tagline: 'Scholarly papers from arXiv, Google Scholar, PubMed-style sources.' }, + { value: 'writingAssistant', label: 'Writing Assistant', tagline: 'No web search — pure writing help (drafting, rewriting, summarizing).' }, + { value: 'wolframAlpha', label: 'Wolfram Alpha', tagline: 'Computational / factual queries — math, units, structured data.' }, + { value: 'youtubeSearch', label: 'YouTube Search', tagline: 'Video results with transcripts when available.' }, + { value: 'redditSearch', label: 'Reddit Search', tagline: 'Discussion threads — opinions, anecdotes, community knowledge.' }, +]; + +const DEFAULT_FOCUS = 'webSearch'; + +const OPTIMIZATION_MODES: Array<{ value: string; label: string; tagline: string }> = [ + { value: 'speed', label: 'Speed', tagline: 'Fewest sources, quickest answer. Good for simple questions.' }, + { value: 'balanced', label: 'Balanced', tagline: 'Default — reasonable depth without long waits.' }, + { value: 'quality', label: 'Quality', tagline: 'More sources and deeper synthesis. Slower but more thorough.' }, +]; + +const DEFAULT_OPTIMIZATION = 'balanced'; + export class PerplexicaModal extends Modal { private editor: Editor; private perplexicaService: PerplexicaService; private promptsService: PromptsService; - private queryInput!: HTMLTextAreaElement; - private focusModeSelect!: HTMLSelectElement; - private optimizationSelect!: HTMLSelectElement; - private streamToggle!: HTMLInputElement; - private imagesToggle!: HTMLInputElement; - constructor(app: App, editor: Editor, perplexicaService: PerplexicaService, promptsService: PromptsService) { + private query = ''; + private focusMode: string = DEFAULT_FOCUS; + private optimization: string = DEFAULT_OPTIMIZATION; + private images = false; + private stream = false; + + // Live-updating description elements below the dropdowns + private focusDescEl: HTMLElement | null = null; + private optimizationDescEl: HTMLElement | null = null; + + constructor( + app: App, + editor: Editor, + perplexicaService: PerplexicaService, + promptsService: PromptsService + ) { super(app); this.editor = editor; this.perplexicaService = perplexicaService; this.promptsService = promptsService; } - - onOpen() { - const {contentEl} = this; - contentEl.addClass('perplexica-modal'); - contentEl.createEl('h2', {text: 'Ask Perplexica'}); - - 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; + // Attach to modalEl so width rules widen the popup itself. + modalEl.addClass('perplexica-modal'); + contentEl.empty(); + + // ----- Header ----- + const header = contentEl.createDiv({ cls: 'perplexica-modal__header' }); + header.createEl('h2', { text: 'Ask Perplexica', cls: 'perplexica-modal__title' }); + header.createEl('p', { + cls: 'perplexica-modal__subtitle', + text: 'Self-hosted, source-grounded answers via your local Perplexica instance. Streams into the active note at the cursor.', + }); + + // ----- Question ----- + const querySection = contentEl.createDiv({ cls: 'perplexica-modal__section' }); + querySection.createEl('label', { + text: 'Question', + cls: 'perplexica-modal__label', + attr: { for: 'perplexica-modal-query' }, + }); + const queryTextarea = querySection.createEl('textarea', { + cls: 'perplexica-modal__textarea', attr: { - rows: '4', - placeholder: this.promptsService.getPerplexicaQueryPlaceholder() + id: 'perplexica-modal-query', + rows: '6', + placeholder: this.promptsService.getPerplexicaQueryPlaceholder(), + }, + }); + queryTextarea.value = this.query; + queryTextarea.addEventListener('input', () => { + this.query = queryTextarea.value; + }); + // Cmd/Ctrl+Enter submits + queryTextarea.addEventListener('keydown', (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + void this.onSubmit(); } }); - // Focus mode selection - const focusDiv = form.createDiv({cls: 'setting-item'}); - focusDiv.createEl('label', {text: 'Focus Mode'}); - this.focusModeSelect = focusDiv.createEl('select', {cls: 'dropdown'}); - ['webSearch', 'academicSearch', 'writingAssistant', 'wolframAlpha', 'youtubeSearch', 'redditSearch'].forEach(mode => { - const option = this.focusModeSelect.createEl('option', {value: mode, text: mode}); - if (mode === 'webSearch') option.selected = true; + // ----- Search ----- + const searchSection = contentEl.createDiv({ cls: 'perplexica-modal__section' }); + searchSection.createEl('h3', { text: 'Search', cls: 'perplexica-modal__section-title' }); + + const focusSetting = new Setting(searchSection) + .setName('Focus Mode') + .setDesc(this.focusTagline(this.focusMode)) + .addDropdown(dd => { + FOCUS_MODES.forEach(({ value, label }) => dd.addOption(value, label)); + dd.setValue(this.focusMode); + dd.onChange((value) => { + this.focusMode = value; + if (this.focusDescEl) this.focusDescEl.textContent = this.focusTagline(value); + }); + }); + this.focusDescEl = focusSetting.descEl; + + const optimizationSetting = new Setting(searchSection) + .setName('Optimization') + .setDesc(this.optimizationTagline(this.optimization)) + .addDropdown(dd => { + OPTIMIZATION_MODES.forEach(({ value, label }) => dd.addOption(value, label)); + dd.setValue(this.optimization); + dd.onChange((value) => { + this.optimization = value; + if (this.optimizationDescEl) this.optimizationDescEl.textContent = this.optimizationTagline(value); + }); + }); + this.optimizationDescEl = optimizationSetting.descEl; + + // ----- Returns ----- + const returnsSection = contentEl.createDiv({ cls: 'perplexica-modal__section' }); + returnsSection.createEl('h3', { text: 'Include in response', cls: 'perplexica-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: 'perplexica-modal__section' }); + behaviorSection.createEl('h3', { text: 'Behavior', cls: 'perplexica-modal__section-title' }); + + new Setting(behaviorSection) + .setName('Stream Response') + .setDesc('Write tokens into the note as they arrive — useful for long answers and slow local models.') + .addToggle(t => t + .setValue(this.stream) + .onChange(v => { this.stream = v; })); + + // ----- Footer ----- + const footer = contentEl.createDiv({ cls: 'perplexica-modal__footer' }); + const cancelBtn = footer.createEl('button', { + text: 'Cancel', + cls: 'perplexica-modal__button', }); + cancelBtn.addEventListener('click', () => this.close()); - // Optimization mode selection - const optimizationDiv = form.createDiv({cls: 'setting-item'}); - optimizationDiv.createEl('label', {text: 'Optimization'}); - this.optimizationSelect = optimizationDiv.createEl('select', {cls: 'dropdown'}); - ['speed', 'balanced', 'quality'].forEach(mode => { - const option = this.optimizationSelect.createEl('option', {value: mode, text: mode}); - if (mode === 'balanced') option.selected = true; - }); - - // 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(); - - // Stream toggle - const streamDiv = form.createDiv({cls: 'setting-item'}); - const streamLabel = streamDiv.createEl('label'); - this.streamToggle = streamLabel.createEl('input', {type: 'checkbox'}); - this.streamToggle.checked = false; - streamLabel.createSpan({text: ' Stream response'}); - - const buttonDiv = contentEl.createDiv({cls: 'setting-item'}); - const askButton = buttonDiv.createEl('button', { + const askBtn = footer.createEl('button', { text: 'Ask Perplexica', - cls: 'mod-cta' + cls: 'perplexica-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()); + + // Focus the question after the DOM has settled + setTimeout(() => queryTextarea.focus(), 50); } - - async onSubmit() { - const query = this.queryInput.value.trim(); - if (!query) { + + private focusTagline(value: string): string { + return FOCUS_MODES.find(m => m.value === value)?.tagline ?? ''; + } + + private optimizationTagline(value: string): string { + return OPTIMIZATION_MODES.find(m => m.value === value)?.tagline ?? ''; + } + + private async onSubmit(): Promise { + 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()}`; + // If images are enabled, append the image-references prompt so the model + // is instructed to embed image markers throughout the response. + let processedQuery = trimmed; + if (this.images) { + processedQuery = `${trimmed}\n\n${this.promptsService.getImageReferencesPrompt()}`; } const options: PerplexicaOptions = { - return_images: this.imagesToggle.checked + return_images: this.images, }; this.close(); await this.perplexicaService.queryPerplexica( - processedQuery, - this.focusModeSelect.value, - this.optimizationSelect.value, - this.streamToggle.checked, + processedQuery, + this.focusMode, + this.optimization, + this.stream, this.editor, options ); } - - onClose() { - const {contentEl} = this; - contentEl.empty(); + + onClose(): void { + this.contentEl.empty(); } -} +} diff --git a/src/styles/perplexica-modal.css b/src/styles/perplexica-modal.css index e0e6e88..30eb72b 100644 --- a/src/styles/perplexica-modal.css +++ b/src/styles/perplexica-modal.css @@ -1,7 +1,179 @@ -/* PerplexicaModal Styles */ +/* ============================================================ + Perplexica Modal — local-first research query composer + Built on Obsidian's native Setting components for theming + parity, with a custom textarea + footer for feel. + ============================================================ */ -.perplexica-modal .text-input { +.perplexica-modal { + /* Modal container — wider and breathier than default */ + width: 90vw; + max-width: 640px; +} + +.perplexica-modal .modal-content { + padding: 0; +} + +/* ----- Header ----- */ + +.perplexica-modal__header { + padding: 24px 28px 16px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.perplexica-modal__title { + margin: 0 0 6px; + font-size: 1.5em; + font-weight: 600; + color: var(--text-normal); + letter-spacing: -0.01em; +} + +.perplexica-modal__subtitle { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--text-muted); +} + +/* ----- Sections ----- */ + +.perplexica-modal__section { + padding: 18px 28px 4px; +} + +.perplexica-modal__section + .perplexica-modal__section { + border-top: 1px solid var(--background-modifier-border-hover); + margin-top: 4px; +} + +.perplexica-modal__section-title { + margin: 0 0 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +/* Tighten the native Setting spacing inside our sections */ +.perplexica-modal__section .setting-item { + padding: 10px 0; + border-top: none; +} + +.perplexica-modal__section .setting-item + .setting-item { + border-top: 1px solid var(--background-modifier-border-hover); +} + +/* ----- Question label + textarea ----- */ + +.perplexica-modal__label { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.perplexica-modal__textarea { width: 100%; - margin: 8px 0; - padding: 12px; -} \ No newline at end of file + 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; +} + +.perplexica-modal__textarea:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 3px var(--interactive-accent-hover); +} + +.perplexica-modal__textarea::placeholder { + color: var(--text-faint); +} + +/* ----- Footer / actions ----- */ + +.perplexica-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); +} + +.perplexica-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; +} + +.perplexica-modal__button:hover { + background-color: var(--background-modifier-hover); +} + +.perplexica-modal__button:active { + transform: translateY(1px); +} + +.perplexica-modal__button.mod-cta { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +.perplexica-modal__button.mod-cta:hover { + background-color: var(--interactive-accent-hover); + border-color: var(--interactive-accent-hover); +} + +.perplexica-modal__button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ----- Responsive ----- */ + +@media (max-width: 600px) { + .perplexica-modal { + width: 95vw; + max-width: none; + } + + .perplexica-modal__header, + .perplexica-modal__section, + .perplexica-modal__footer { + padding-left: 18px; + padding-right: 18px; + } + + .perplexica-modal__footer { + flex-direction: column-reverse; + } + + .perplexica-modal__button { + width: 100%; + } +} diff --git a/styles.css b/styles.css index 199996c..a1d788f 100644 --- a/styles.css +++ b/styles.css @@ -299,10 +299,145 @@ } /* src/styles/perplexica-modal.css */ -.perplexica-modal .text-input { +.perplexica-modal { + width: 90vw; + max-width: 640px; +} +.perplexica-modal .modal-content { + padding: 0; +} +.perplexica-modal__header { + padding: 24px 28px 16px; + border-bottom: 1px solid var(--background-modifier-border); +} +.perplexica-modal__title { + margin: 0 0 6px; + font-size: 1.5em; + font-weight: 600; + color: var(--text-normal); + letter-spacing: -0.01em; +} +.perplexica-modal__subtitle { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--text-muted); +} +.perplexica-modal__section { + padding: 18px 28px 4px; +} +.perplexica-modal__section + .perplexica-modal__section { + border-top: 1px solid var(--background-modifier-border-hover); + margin-top: 4px; +} +.perplexica-modal__section-title { + margin: 0 0 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} +.perplexica-modal__section .setting-item { + padding: 10px 0; + border-top: none; +} +.perplexica-modal__section .setting-item + .setting-item { + border-top: 1px solid var(--background-modifier-border-hover); +} +.perplexica-modal__label { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} +.perplexica-modal__textarea { width: 100%; - margin: 8px 0; - padding: 12px; + 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; +} +.perplexica-modal__textarea:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 3px var(--interactive-accent-hover); +} +.perplexica-modal__textarea::placeholder { + color: var(--text-faint); +} +.perplexica-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); +} +.perplexica-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; +} +.perplexica-modal__button:hover { + background-color: var(--background-modifier-hover); +} +.perplexica-modal__button:active { + transform: translateY(1px); +} +.perplexica-modal__button.mod-cta { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} +.perplexica-modal__button.mod-cta:hover { + background-color: var(--interactive-accent-hover); + border-color: var(--interactive-accent-hover); +} +.perplexica-modal__button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +@media (max-width: 600px) { + .perplexica-modal { + width: 95vw; + max-width: none; + } + .perplexica-modal__header, + .perplexica-modal__section, + .perplexica-modal__footer { + padding-left: 18px; + padding-right: 18px; + } + .perplexica-modal__footer { + flex-direction: column-reverse; + } + .perplexica-modal__button { + width: 100%; + } } /* src/styles/lmstudio-modal.css */