mirror of
https://github.com/lossless-group/perplexed-plugin.git
synced 2026-07-22 06:49:50 +00:00
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:
parent
46e4f24755
commit
19b300c10c
7 changed files with 1615 additions and 116 deletions
481
changelog/2026-04-30_01.md
Normal file
481
changelog/2026-04-30_01.md
Normal 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
597
changelog/2026-05-01_01.md
Normal 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.
|
||||
|
|
@ -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? |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
141
styles.css
141
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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue