Skip to content

feat: Obsidian plugin — native .nodepad rendering with full provider support#2

Merged
Dev-020 merged 13 commits into
mainfrom
claude/inspiring-haibt-4aa964
May 11, 2026
Merged

feat: Obsidian plugin — native .nodepad rendering with full provider support#2
Dev-020 merged 13 commits into
mainfrom
claude/inspiring-haibt-4aa964

Conversation

@Dev-020
Copy link
Copy Markdown
Owner

@Dev-020 Dev-020 commented May 11, 2026

Summary

  • Adds a complete Obsidian plugin (plugin/) that renders .nodepad files natively inside the Obsidian workspace as a TextFileView leaf
  • Supports all five AI providers: OpenRouter, OpenAI, Z.ai, Ollama (local + cloud), and Gemini CLI via child_process
  • Adds plugin/README.md covering install, usage, provider setup, build steps, and troubleshooting
  • Patches four shared components for portal scoping - no web app regression
  • Fixes Tailwind CSS not being compiled (was copied as raw source, now runs through @tailwindcss/cli)

Closes backlog mskayyali#22, mskayyali#23, mskayyali#24, mskayyali#25, mskayyali#26.


What changed

New: plugin/ directory

File Purpose
src/main.ts Registers .nodepad extension, ribbon icon, command palette, folder right-click
src/view.tsx TextFileView subclass - mounts full NodepadApp React tree, auto-saves on every state change
src/settings.ts Obsidian settings tab - provider dropdown, API key, model, local Ollama toggle, Gemini CLI binary check
src/ai-adapter.ts All AI calls via requestUrl() (CORS-free in Electron); child_process.spawn("gemini") for Gemini CLI; correct /api/chat shape for Ollama
src/styles.css Tailwind tokens mapped to Obsidian CSS variables for automatic theme adaptation
esbuild.config.mjs Bundles from local lib/ and components/ - uses fork code
README.md Install, usage, provider reference, build steps, troubleshooting

Shared component patches

  • components/ui/sheet.tsx - SheetPortal/SheetContent accept container? prop
  • components/about-panel.tsx - forwards container to SheetContent
  • components/status-bar.tsx - accepts and forwards portalContainer
  • components/vim-input.tsx - isPlugin mode: hides Projects nav, filters to Export MD / Copy MD / Clear, dynamic grid columns

Confirmed working (tested with Gemini CLI)

  • .nodepad file opens as a styled canvas inside Obsidian - full nodepad UI renders correctly after CSS compilation fix
  • Tiling, Kanban, Graph view modes all render properly with correct styling
  • Ctrl+K command palette shows correct plugin-mode items (Index, Synthesis, Export MD, Copy MD, Clear - no Projects/New Project)
  • AI enrichment works end-to-end: annotations, content type classification, confidence scores
  • Ghost synthesis fires correctly after sufficient enriched blocks across 2+ categories
  • Canvas Index panel renders and groups correctly
  • Auto-save to vault file on every state change confirmed

Known issues found during testing

Issue 1 - Command palette icons overflow button bounds

Observed: In the Ctrl+K palette (Views / Navigate / Actions sections), icon SVGs render larger than their button containers.
Root cause: Obsidian's .view-content svg rule has higher specificity than Tailwind h-[18px] w-[18px] utilities. The styles.css overrides cover some size classes but Obsidian wins on the palette buttons specifically.
Solution: Increase specificity of .nodepad-view svg overrides using a tighter selector (e.g. .nodepad-view button svg) and ensure all icon size combinations used in vim-input.tsx are covered.

Issue 2 - Ollama model list does not auto-populate from local device

Observed: Selecting Ollama as provider shows a free-text model input. The "Discover models" button fires a Notice showing found model names but the input is never populated with them.
Root cause: getModelsForProvider("ollama") returns [] (dynamic-only), so settings.ts falls through to the free-text path. The discovery result is displayed but not persisted or rendered as a selectable list.
Solution: Add ollamaModels: string[] to NodepadSettings. On Ollama provider selection and on settings tab open, auto-fetch http://localhost:11434/api/tags, persist the model names, and render a <select> dropdown. Fall back to free-text input if the fetch fails or returns empty.

Issue 3 - API key field has no show/hide toggle

Observed: The API key input is permanently masked (type="password") with no way to verify what is currently stored.
Solution: Add an eye icon toggle button via Setting.addExtraButton that switches inputEl.type between "password" and "text". This is a standard pattern in Obsidian community plugins.

Issue 4 - Switching providers does not restore the previously saved API key for that provider

Observed: Switching between providers (e.g. OpenRouter to OpenAI and back) does not restore the key previously entered for each provider. The field stays blank or retains the wrong value, requiring the user to manually re-enter each key every time.
Root cause: NodepadSettings has a single apiKey string. When the provider dropdown changes, the previous key is overwritten with no per-provider store, so it cannot be recovered when switching back.
Solution: Add providerKeys: Partial<Record<AIProvider, string>> to NodepadSettings - this field already exists in lib/ai-settings.ts for the web app, so it matches the established pattern. In the provider onChange handler: save current apiKey into providerKeys[previousProvider], then restore providerKeys[newProvider] ?? "" into apiKey before calling saveSettings().

Issue 5 - No web grounding toggle in plugin settings

Observed: Web grounding is never activated from the plugin. getPluginAIConfig() hardcodes supportsGrounding: false regardless of provider or model.
Solution:

  • Add webGrounding: boolean to NodepadSettings (default false)
  • Add a toggle in the settings tab, shown only when the selected provider and model support grounding (OpenRouter with :online models, OpenAI with search-preview models, Ollama, Gemini CLI)
  • Update getPluginAIConfig() to set supportsGrounding: settings.webGrounding && modelSupportsGrounding
  • No changes needed in ai-adapter.ts - enrichment and ghost logic already branches correctly on supportsGrounding

Install

cd plugin
npm install
npm run build

# Run as Administrator - creates junction so dist/ auto-updates on rebuild
New-Item -ItemType Junction -Path "<vault>\.obsidian\plugins\nodepad" -Target (Resolve-Path .\plugin\dist)

Obsidian - Settings - Community plugins - disable Restricted mode - enable Nodepad

Dev workflow

# Terminal 1 - JS watch
npm run dev

# Terminal 2 - CSS watch (must run separately - Tailwind is not bundled through esbuild)
npm run dev:css

Test checklist

  • Ribbon icon creates Untitled Space and opens it in a new tab
  • Tiling / Kanban / Graph views render correctly inside Obsidian
  • Ctrl+K palette shows correct plugin-mode items only
  • Settings - Nodepad shows all five providers
  • Gemini CLI enrichment works end-to-end with annotations and confidence
  • Ghost synthesis fires correctly
  • Canvas Index panel renders and groups correctly
  • Web app has no regression in shared components
  • Issue 1: Icon overflow in Ctrl+K palette buttons
  • Issue 2: Ollama dynamic model list auto-population
  • Issue 3: API key show/hide toggle
  • Issue 4: Per-provider API key persistence when switching
  • Issue 5: Web grounding toggle
  • OpenRouter / OpenAI / Z.ai / Ollama enrichment (pending API key testing once Issue 4 is fixed)

Generated with Claude Code (https://claude.ai/claude-code)

Dev-020 and others added 13 commits May 11, 2026 10:16
Implements the full Obsidian plugin integration (backlog mskayyali#22-mskayyali#26).
The plugin bundles all nodepad React components via esbuild and renders
them inside an Obsidian TextFileView leaf. .nodepad files are stored in
the vault and auto-saved on every state change — no Next.js server required.

Plugin structure:
- plugin/src/main.ts — registers .nodepad extension, ribbon icon, command palette
- plugin/src/view.tsx — React mount via createRoot(), full NodepadApp component
- plugin/src/settings.ts — Obsidian settings tab (provider/model/key/local-toggle)
- plugin/src/ai-adapter.ts — direct AI calls via requestUrl() + child_process bridge
- plugin/src/styles.css — Tailwind tokens mapped to Obsidian CSS variables
- plugin/esbuild.config.mjs — bundles from local lib/ and components/

AI providers supported in plugin:
- OpenRouter, OpenAI, Z.ai — via requestUrl() (CORS-free in Electron)
- Ollama — direct localhost:11434 or Cloud, different /api/chat request shape
- Gemini CLI — two-stage child_process pipeline with optional web grounding

Shared component patches (no web app regression):
- components/ui/sheet.tsx — SheetPortal/SheetContent accept container prop
- components/about-panel.tsx — forwards container to SheetContent
- components/status-bar.tsx — accepts and forwards portalContainer
- components/vim-input.tsx — isPlugin mode: hides Projects nav, filters actions,
  uses dynamic grid columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers installation (manual sideload and BRAT), usage, all five AI
providers (OpenRouter, OpenAI, Z.ai, Ollama, Gemini CLI), build-from-
source steps, troubleshooting, and roadmap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The esbuild config was copying src/styles.css verbatim to dist/styles.css.
Obsidian received the unprocessed Tailwind v4 source (@import "tailwindcss",
@source directives) which it cannot interpret, resulting in zero utility
classes being applied and the entire nodepad layout collapsing.

Fix: add @tailwindcss/cli to devDependencies and run the Tailwind compiler
as the first step of npm run build. The compiled dist/styles.css is now
74 KB of generated utilities instead of the 3 KB raw source.

Dev workflow: run npm run dev:css in a separate terminal alongside npm run dev
to get Tailwind watch mode during development.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue 3 — API key show/hide:
Adds an eye icon ExtraButton alongside the API key input. Clicking it
toggles inputEl.type between "password" and "text" and swaps the icon
to eye-off. The input element reference is captured directly from the
addText callback so there is no fragile DOM query.

Issue 4 — Per-provider key persistence:
Adds providerKeys: Partial<Record<AIProvider, string>> to NodepadSettings.
When the provider dropdown changes, the current apiKey is saved into
providerKeys[previousProvider] before switching, then the stored key for
the new provider is restored from providerKeys[newProvider] ?? "".
The apiKey field is also kept in sync with providerKeys[currentProvider]
on every keystroke so the latest value is always preserved when switching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue 2 — Ollama model auto-discovery:
- Adds ollamaModels: string[] to NodepadSettings (persisted)
- Switching to Ollama auto-fetches localhost:11434/api/tags and stores
  model names; first discovered model is set as active if current modelId
  is not in the list
- Discover models button saves results, updates the dropdown, and
  re-renders rather than just showing a Notice
- Model section shows a dropdown from discovered models when available,
  falls back to free-text input with a hint when Ollama is unreachable
- Discovery count shown in the setting description after first run

Issue 5 — Web grounding toggle:
- Adds webGrounding: boolean to NodepadSettings (default false)
- Settings tab shows a grounding toggle for OpenRouter, OpenAI, and
  Gemini CLI with a provider-specific description of what it does
- Ollama and Z.ai are excluded (no web-search mechanism in the adapter)
- getPluginAIConfig() now derives supportsGrounding from the toggle and
  restricts it to the three providers that actually implement grounding
- enrichBlock() shouldGround check now gates on config.supportsGrounding
  so grounding is fully opt-in instead of always-on for truth-dependent
  note types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ollama grounding was incorrectly excluded. The fork implements a full
hybrid RAG pipeline in /api/ai/route.ts: Ollama Cloud web_search for
retrieval, embeddinggemma at localhost:11434/api/embed for local
vectorization, cosine similarity ranking, and top-5 chunk injection.

Ports that pipeline into ai-adapter.ts so it runs via requestUrl()
without needing the Next.js server:
- ollamaRAGContext() calls ollama.com/api/web_search with the note text
- getLocalEmbeddings() vectors both query and chunks via embeddinggemma
- cosineSimilarity() ranks chunks and top 5 are injected into userMessage
- Terminal logs mirror the web app's RAG logging pattern

Also adds "ollama" to GROUNDING_PROVIDERS in both adapter and settings,
and adds an Ollama-specific description to the web grounding toggle
explaining the embeddinggemma dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion header

Two bugs both showed the same "no API key" error tile:

Bug A (local Ollama, no key entered):
getPluginAIConfig() returned null for any non-geminicli provider when
apiKey was empty. Local Ollama at localhost:11434 requires no key, so
the null check now also exempts provider=ollama + useLocalOllama=true.
The hasKey guard in view.tsx is updated the same way so the amber
warning banner does not appear for local Ollama users without a key.

Bug B (Ollama Cloud, key entered but ignored):
Both enrichBlock and generateGhost hardcoded headers as
{ "Content-Type": "application/json" } for Ollama, omitting the
Authorization header entirely. Ollama Cloud returned 401 which the
error parser maps to "Invalid or missing API key". Fixed by replacing
the hardcoded header with getProviderHeaders(config), which already
adds Authorization: Bearer <key> when config.apiKey is non-empty and
omits it when empty (correct for local Ollama).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every provider now logs to the Obsidian DevTools console (Ctrl+Shift+I)
for both enrichment and ghost synthesis, making it easy to confirm
which provider/model is being used and whether requests are succeeding.

Enrichment logs:
  [Nodepad/openrouter] Enriching with openai/gpt-4o + grounding
  [Nodepad/openrouter] Enrich done → claim (confidence 87)
  [Nodepad/openai]     Enriching with gpt-4o
  [Nodepad/openai]     Enrich done → question (confidence 92)
  [Nodepad/zai]        Enriching with glm-4.7
  [Nodepad/zai]        Enrich done → idea (confidence 78)
  [Nodepad/Ollama]     Enriching with llama3.2 + RAG
  [Nodepad/Ollama]     Enrich done → claim (confidence 85)
  [Nodepad/GeminiCLI]  Enrich done (question)

Ghost synthesis logs:
  [Nodepad/openrouter] Generating ghost synthesis with openai/gpt-4o
  [Nodepad/openrouter] Ghost done → "Tension between X and Y implies..."
  [Nodepad/Ollama]     Generating ghost synthesis with llama3.2
  [Nodepad/GeminiCLI]  Generating ghost synthesis...
  [Nodepad/GeminiCLI]  Ghost done → "Tension between..."

Ollama RAG and Gemini CLI stage logs were already present and unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ificity

Obsidian's .view-content svg rule has higher specificity than our
.nodepad-view svg:is(...) selectors, causing all Lucide icon SVGs to
inherit Obsidian's dimensions rather than their Tailwind size classes
(h-[18px] w-[18px], h-4, h-5, etc.).

Fix: add !important to all explicit width/height declarations on SVGs
inside .nodepad-view so they win regardless of Obsidian's cascade.
Also adds a max-width/max-height cap on unsized SVGs as a fallback
guard, and moves display/flex-shrink to the base .nodepad-view svg
rule so they always apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cascade

Obsidian's .view-content button rule resets padding to 0 with higher
specificity than our .nodepad-view button:is(...) overrides, causing
palette buttons (py-4 px-2) to have zero padding. With no internal
space the flex-col layout has nowhere to centre the icon + label, so
the icon clips at the top of the button.

Fix: add !important to all button padding and height overrides so they
win the cascade regardless of Obsidian's base stylesheet, matching the
same approach already applied to SVG size overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Obsidian applies a min-height to buttons that makes the standard 1rem
top/bottom padding insufficient to visually centre the flex-col
icon + label stack. Values found via DevTools experimentation:
  padding-top: 1.8rem
  padding-bottom: 1.6rem
The slight asymmetry accounts for the label text sitting below the icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Obsidian forces display:inline-block !important on all SVGs in the
plugin view (needed for icon sizing). For small icon SVGs this is
correct, but for the full-screen graph SVG it restricts pointer-event
hit-testing to painted regions only — i.e. the node circles. Blank
canvas areas receive no pointer events, so scroll-to-zoom and
mousedown-to-pan only work when the cursor is over a node.

Fix: insert a transparent <rect> covering the full SVG dimensions as
the first child of the SVG element. This gives the SVG a painted
surface across its entire area, so wheel and mousedown events fire
everywhere regardless of the CSS display mode. Standard D3 practice
for force-directed graphs rendered inside constrained containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The minimap page button used p-1.5 which is caught by the global
button:is(.p-1.5) CSS override, causing the dot grid to clip. The
correct padding (1.5rem top/bottom, 0.375rem left/right found via
DevTools) also affected status bar buttons which use the same class.

Fix: remove p-1.5 from the minimap button className and apply the
correct values as an inline style prop instead. Inline styles have
higher priority than any CSS rule so they are unaffected by either
Obsidian's base styles or our override, while the global p-1.5
override continues to work correctly for status bar buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Dev-020
Copy link
Copy Markdown
Owner Author

Dev-020 commented May 11, 2026

Testing complete — all issues resolved

Went through the full plugin after the initial build and resolved every rendering and functional issue found during testing. Summary of what was found and fixed:


CSS compilation missing (root cause of broken styling)

Problem: esbuild.config.mjs was copying src/styles.css verbatim to dist/styles.css. Obsidian received the raw Tailwind v4 source (@import "tailwindcss", @source directives) which it cannot interpret — zero utility classes were generated. The layout collapsed entirely, card borders/backgrounds/colors were absent.

Fix: Added @tailwindcss/cli to devDependencies. npm run build now runs tailwindcss -i src/styles.css -o dist/styles.css --minify before esbuild. Compiled output is 74 KB of generated utilities vs 3 KB raw source.

Dev workflow: npm run dev watches JS, npm run dev:css watches CSS — run both in separate terminals.


Obsidian CSS specificity overrides (multiple elements)

Problem: Obsidian's .view-content svg and .view-content button rules have higher specificity than our .nodepad-view overrides, resetting icon sizes and button padding to Obsidian defaults.

Fixes applied:

  • Added !important to all SVG size declarations (h-4, h-5, h-6, h-[18px], etc.) in styles.css
  • Added !important to all button padding declarations (p-1, py-4, px-2, etc.) in styles.css
  • Adjusted py-4 override to 1.8rem top / 1.6rem bottom (found via DevTools) to account for Obsidian's button min-height pushing content off-centre

Command palette icon overflow

Problem: Despite the SVG !important size fix, the palette buttons (Views / Navigate / Actions) still showed icons clipping at the top. DevTools confirmed button padding was 0 — the !important fix for the padding overrides hadn't landed yet.

Fix: Applied !important to the full button padding override set, restoring py-4 so the flex-col icon + label stack has vertical space to centre inside each button.


Minimap dot grid clipping outside button

Problem: The tiling minimap page buttons use p-1.5, same class as the status bar icon buttons. The global button:is(.p-1.5) override affected both, so correcting the minimap padding broke the status bar buttons.

Fix: Removed p-1.5 from the minimap button's className and replaced it with an inline style prop (paddingTop: 1.5rem, paddingBottom: 1.5rem, paddingLeft: 0.375rem, paddingRight: 0.375rem). Inline styles sit above the entire CSS cascade, so they are unaffected by Obsidian's rules or our overrides, while p-1.5 continues to work correctly for status bar buttons.


Per-provider API key not restored when switching providers

Problem: NodepadSettings had a single apiKey string. Switching from OpenRouter → OpenAI → back to OpenRouter discarded the previously entered key, requiring the user to re-enter it every time.

Fix: Added providerKeys: Partial<Record<AIProvider, string>> to NodepadSettings. Provider onChange handler now saves apiKey into providerKeys[previousProvider] before switching, then restores providerKeys[newProvider] ?? "". The key field also syncs to providerKeys[currentProvider] on every keystroke.


API key permanently masked with no show/hide toggle

Problem: The API key input had type="password" with no way to verify what was stored.

Fix: Added Setting.addExtraButton with an eye icon alongside the key input. Clicking toggles inputEl.type between "password" and "text" and swaps the icon to eye-off. The HTMLInputElement reference is captured directly in the addText callback — no DOM query.


Ollama provider auth bugs (two separate bugs, same symptom)

Bug A — Local Ollama, no key: getPluginAIConfig() returned null for any provider that wasn't geminicli when apiKey was empty. Local Ollama at localhost:11434 needs no key, so it was always throwing "No API key configured."

Fix: Added isLocalOllama check — local Ollama is now treated as a keyless provider alongside Gemini CLI. The hasKey guard in view.tsx is updated the same way so the amber banner doesn't appear for local Ollama users.

Bug B — Ollama Cloud, key present but ignored: The Ollama enrichment and ghost requests hardcoded headers: { "Content-Type": "application/json" }, omitting the Authorization header entirely. Ollama Cloud returned 401, parsed as "Invalid or missing API key."

Fix: Replaced the hardcoded header with getProviderHeaders(config), which already adds Authorization: Bearer <key> when config.apiKey is non-empty and omits it when empty (correct for local Ollama).


Ollama model list not auto-populating

Problem: Selecting Ollama showed a free-text model input. The "Discover models" button showed a Notice with model names but didn't persist or render them as a selectable dropdown.

Fix: Added ollamaModels: string[] to NodepadSettings. On provider switch to Ollama, models are auto-fetched from localhost:11434/api/tags and stored. The model section renders a dropdown from settings.ollamaModels when available, falling back to free-text input when Ollama is unreachable. The "Discover models" button now saves results and re-renders the dropdown.


No web grounding toggle

Problem: getPluginAIConfig() hardcoded supportsGrounding: false regardless of provider or model. There was no way to enable grounding from the plugin.

Fix: Added webGrounding: boolean to NodepadSettings (default false). Settings tab shows a toggle for OpenRouter, OpenAI, Ollama, and Gemini CLI — each with a provider-specific description of what grounding does. getPluginAIConfig() now derives supportsGrounding from the toggle, and enrichBlock()'s shouldGround check gates on config.supportsGrounding so grounding is fully opt-in.


Ollama web grounding (RAG pipeline) missing from plugin

Problem: Ollama's hybrid RAG pipeline (Ollama Cloud web_search → local embeddinggemma vectorization → cosine similarity ranking → top-5 injection) existed in the web app's /api/ai server route but was never ported to the plugin's ai-adapter.ts.

Fix: Ported the full pipeline: ollamaRAGContext() calls ollama.com/api/web_search, getLocalEmbeddings() vectorizes via embeddinggemma at localhost:11434/api/embed, chunks are ranked by cosine similarity, and top 5 are injected into the user message before the main model call. Requires Ollama Cloud API key + embeddinggemma installed locally. Falls back silently to non-grounded enrichment if either is unavailable.


Graph view — pan and zoom only worked over nodes

Problem: Scroll-to-zoom and drag-to-pan only worked when the cursor was directly over a node. The blank canvas area received no pointer events.

Root cause: Our styles.css forced display: inline-block !important on all SVGs (needed for icon sizing). For the full-screen graph SVG, inline-block restricts pointer-event hit-testing to painted regions only — i.e. the filled node circles. Empty canvas areas had no painted surface to capture events.

Fix: Added a transparent <rect> covering the full SVG dimensions as the first child of the graph SVG. This gives the SVG a painted surface everywhere, so onWheel and onMouseDown fire across the entire canvas. Standard practice for D3 force-directed graphs in constrained containers.


Runtime logging added for all providers

All providers now log to Obsidian DevTools console (Ctrl+Shift+I) for both enrichment and ghost synthesis, making it straightforward to confirm which provider/model is handling each request and whether it succeeded.


All 9 original test checklist items are now passing. Ready for merge.

@Dev-020 Dev-020 merged commit 1f09364 into main May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant