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
This commit is contained in:
mpstaton 2026-05-02 17:07:10 -05:00
parent 46e4f24755
commit 19b300c10c
7 changed files with 1615 additions and 116 deletions

481
changelog/2026-04-30_01.md Normal file
View file

@ -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<void>;
}
```
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<string, ClaudeWebCitation>();
// 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).

597
changelog/2026-05-01_01.md Normal file
View file

@ -0,0 +1,597 @@
---
title: "UX pass: modal redesign + wide-modal CSS unlock"
lede: "The three 'Ask <provider>' 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 <provider>` 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 <provider>' 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 (`.<provider>-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 `<h3>` 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.

View file

@ -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? |

View file

@ -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.
## 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<string> // ("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.

View file

@ -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<void> {
const trimmed = this.query.trim();
if (!trimmed) {
new Notice(this.promptsService.getEnterQuestionNotice());
return;
}
// If images are enabled, add image markers to the query
let processedQuery = query;
if (this.imagesToggle.checked) {
processedQuery = `${query}
${this.promptsService.getImageReferencesPrompt()}`;
// 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();
}
}
}

View file

@ -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;
}
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%;
}
}

View file

@ -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 */