import type { UpdateCheckSetting } from '../hooks/useUpdateCheckSetting'; import React, { useState, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal'; import type { Origin } from '@plannotator/core/agents'; import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import type { TokenHoverDelay } from '@plannotator/core/token-hover'; import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config'; import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference'; import { DIAGRAM_SHADOW_OPTIONS } from '../utils/diagramShadow'; import { agentToolAppliesWhen, agentToolCostNote, agentToolHostName, type AgentToolSetting, } from '../utils/agentToolSetting'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity'; import { GitUser } from '../icons/GitUser'; import { getObsidianSettings, saveObsidianSettings, getEffectiveVaultPath, CUSTOM_PATH_SENTINEL, DEFAULT_FILENAME_FORMAT, type ObsidianSettings, } from '../utils/obsidian'; import { getBearSettings, saveBearSettings, normalizeTags, type BearSettings, } from '../utils/bear'; import { getOctarineSettings, saveOctarineSettings, type OctarineSettings, } from '../utils/octarine'; import { getAgentSwitchSettings, getAgentSwitchDefaults, saveAgentSwitchSettings, AGENT_OPTIONS, type AgentSwitchSettings, type AgentSwitchSurface, } from '../utils/agentSwitch'; import { getPlanSaveSettings, savePlanSaveSettings, type PlanSaveSettings, } from '../utils/planSave'; import { getUIPreferences, saveUIPreferences, PLAN_WIDTH_OPTIONS, type UIPreferences, type PlanWidth, } from '../utils/uiPreferences'; import { getPermissionModeSettings, savePermissionModeSettings, PERMISSION_MODE_OPTIONS, type PermissionMode, } from '../utils/permissionMode'; import { getAutoCloseDelay, setAutoCloseDelay, AUTO_CLOSE_OPTIONS, type AutoCloseDelay } from '../utils/storage'; import { getDefaultNotesApp, saveDefaultNotesApp, type DefaultNotesApp, } from '../utils/defaultNotesApp'; import { useAgents } from '../hooks/useAgents'; import { KeyboardShortcuts } from './KeyboardShortcuts'; import { ThemeTab } from './ThemeTab'; import { modKey, modKeyWord } from '../utils/platform'; import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider'; import { AISettingsTab } from './AISettingsTab'; import { HooksTab } from './settings/HooksTab'; import { QuickLabelsTab } from './settings/QuickLabelsTab'; import { OverlayScrollArea } from './OverlayScrollArea'; import { getFileBrowserSettings, saveFileBrowserSettings, type FileBrowserSettings, } from '../utils/fileBrowser'; import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus'; import { AnalysisLayerToggle } from './AnalysisLayerToggle'; type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks'; interface SettingsProps { taterMode: boolean; onTaterModeChange: (enabled: boolean) => void; onIdentityChange?: (oldIdentity: string, newIdentity: string) => void; origin?: Origin | null; mode?: 'plan' | 'annotate' | 'review'; /** * `mode="annotate"` only: offer the document tabs plan review has (Display, * Saving, Labels, and the Obsidian / Bear / Octarine integrations) and hide * the plan-only OpenCode agent-switch row. Plannotator's annotate app passes * true; a host that omits it keeps the pre-parity annotate tab set. */ annotateParity?: boolean; onUIPreferencesChange?: (prefs: UIPreferences) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; /** Available AI providers (from /api/ai/capabilities). */ aiProviders?: Array<{ id: string; name: string; capabilities: Record; models?: Array<{ id: string; label: string; default?: boolean }> }>; /** Git user name from `git config user.name`, for quick identity set */ gitUser?: string; /** Present when the server advertises the auto-update setting (#1634); * `env` is PLANNOTATOR_AUTO_UPDATE when it overrides the config file. * Hosts that omit it get no toggle. */ autoUpdateSetting?: { env?: boolean }; /** Present when the server reports the update-notice switch (serverConfig.updateCheck, * useUpdateCheckSetting); `env` is PLANNOTATOR_UPDATE_CHECK when it overrides the * config file. Hosts that omit it get no row. */ updateCheckSetting?: UpdateCheckSetting; /** The `plannotator` agent tool switch, as the server reports it * (parseAgentToolSetting of serverConfig: present only when the session's * host registers the tool). Shown only together with onAgentToolChange; * hosts that omit them get no row. */ agentToolSetting?: AgentToolSetting; /** Saves the agent tool switch (saveAgentToolSetting); rejects with a * readable Error, which the row shows. */ onAgentToolChange?: (enabled: boolean) => Promise; /** Current session is a local git review where since-base ISN'T offered * (base ref unresolvable) — the Git tab shows a note that the Git-status * preference can't take effect in THIS repo. */ sinceBaseUnavailable?: boolean; /** The host is rendering its compact touch shell (review only). Display * settings that the compact shell overrides for the session are hidden * there instead of silently editing the desktop preference. */ isCompactTouchLayout?: boolean; /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */ onDetectObsidianVaults?: () => Promise; /** This annotate session actually offers the Agent TUI, so its Position * setting is worth showing. Default false: the setting is the only way back * from a Hidden position, but offering it where no terminal can ever run * would just be a dead control. */ agentTerminalAvailable?: boolean; /** The browser exposes WebMCP (`document.modelContext`), so the "Agent * tools" opt-out is worth showing. Default false: a browser without the * API gets no row at all (the provider registers nothing there). */ webmcpAvailable?: boolean; } // --- Review-mode Display tab (diff display options) --- const DIFF_FONT_OPTIONS = [ { value: '', label: 'Theme Default' }, { value: 'Fira Code', label: 'Fira Code' }, { value: 'Hack', label: 'Hack' }, { value: 'IBM Plex Mono', label: 'IBM Plex Mono' }, { value: 'Inconsolata', label: 'Inconsolata' }, { value: 'JetBrains Mono', label: 'JetBrains Mono' }, { value: 'Red Hat Mono', label: 'Red Hat Mono' }, { value: 'Roboto Mono', label: 'Roboto Mono' }, { value: 'Source Code Pro', label: 'Source Code Pro' }, { value: 'Atkinson Hyperlegible Mono', label: 'Atkinson Hyperlegible' }, ]; export const DIFF_STYLE_OPTIONS = [ { value: 'split' as const, label: 'Split' }, { value: 'unified' as const, label: 'Unified' }, ]; /** * The modifier gate is the platform's primary modifier: Cmd on macOS, Ctrl on * Windows and Linux. Labeled through modKeyWord so the control names the key * the reader actually has. */ export const TOKEN_HOVER_TRIGGER_OPTIONS = [ { value: 'hover' as const, label: 'On hover' }, { value: 'modifier' as const, label: `Hold ${modKeyWord}` }, { value: 'off' as const, label: 'Off' }, ]; /** SegmentedControl keys on strings, so the ms values ride as their digits. */ export type TokenHoverDelayOption = '150' | '300' | '700'; export const TOKEN_HOVER_DELAY_OPTIONS = [ { value: '150' as const, label: 'Fast' }, { value: '300' as const, label: 'Default' }, { value: '700' as const, label: 'Relaxed' }, ]; export const OVERFLOW_OPTIONS = [ { value: 'scroll' as const, label: 'Scroll' }, { value: 'wrap' as const, label: 'Wrap' }, ]; export const INDICATOR_OPTIONS = [ { value: 'bars' as const, label: 'Bars' }, { value: 'classic' as const, label: 'Classic' }, { value: 'none' as const, label: 'None' }, ]; export const LINE_DIFF_OPTIONS = [ { value: 'word-alt' as const, label: 'Word-Alt' }, { value: 'word' as const, label: 'Word' }, { value: 'char' as const, label: 'Char' }, { value: 'none' as const, label: 'None' }, ]; export const LINE_BG_INTENSITY_OPTIONS: { value: DiffLineBgIntensity; label: string }[] = [ { value: 'subtle', label: 'Subtle' }, { value: 'normal', label: 'Normal' }, { value: 'strong', label: 'Strong' }, ]; const DEFAULT_DIFF_TYPE_OPTIONS = [ // "All Changes" belongs to since-base (the flagship composite); uncommitted // reverts to its plain name so the two stay distinguishable side by side. { value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" }, { value: 'local-vs-remote' as const, label: 'Local vs Remote Branch', description: "Your local branch and working tree compared with its last-fetched remote-tracking branch" }, { value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" }, { value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" }, { value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" }, { value: 'merge-base' as const, label: 'Committed changes (PR view)', description: "Everything you've committed on this branch" }, { value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" }, ]; const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: string }[] = [ { value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }, { value: 'hidden', label: 'Hidden' }, ]; function SegmentedControl({ options, value, onChange, disabled = false }: { options: { value: T; label: string }[]; value: T; onChange: (v: T) => void; /** Visible but inert, for a control whose axis does not apply right now. */ disabled?: boolean; }) { return (
{options.map((opt) => ( ))}
); } function ToggleSwitch({ checked, onChange, label, description, disabled = false }: { checked: boolean; onChange: (v: boolean) => void; label: string; description?: string; disabled?: boolean; }) { return (
{label}
{description &&
{description}
}
); } /** * The `plannotator` agent tool switch. The value is the one the NEXT session * uses (the tool list is part of the model's prompt, so a running session * never changes it). Writes go straight to POST /api/config and wait for the * answer, so a failed save is reported and the switch stays where it was. */ function AgentToolSettingRow({ setting, onChange }: { readonly setting: AgentToolSetting; readonly onChange: (enabled: boolean) => Promise; }) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const locked = setting.env !== undefined; const change = async (next: boolean) => { if (saving || locked) return; setSaving(true); setError(null); try { await onChange(next); } catch (e) { setError(e instanceof Error && e.message ? e.message : 'Could not save the setting.'); } finally { setSaving(false); } }; return (
void change(next)} disabled={locked} label="Let the agent open reviews (plannotator tool)" description={ locked ? `Set by PLANNOTATOR_AGENT_TOOL in your environment, so it is ${setting.enabled ? 'on' : 'off'} here.` : `${agentToolHostName(setting.host)} can open reviews itself and keep working. ${agentToolCostNote(setting.host)} Applies from ${agentToolAppliesWhen(setting.host)}.` } /> {error && (

{error}

)}
); } interface VimSettingsTabProps { readonly vimModeEnabled: boolean; readonly vimHudEnabled: boolean; readonly vimHudKeyPanelEnabled: boolean; readonly onVimModeChange: (enabled: boolean) => void; readonly onVimHudChange: (enabled: boolean) => void; readonly onVimHudKeyPanelChange: (enabled: boolean) => void; } /** First-class Vim configuration, separate from the shortcut reference. */ function VimSettingsTab({ vimModeEnabled, vimHudEnabled, vimHudKeyPanelEnabled, onVimModeChange, onVimHudChange, onVimHudKeyPanelChange, }: VimSettingsTabProps) { return (
Vim mode

Navigate, select, and annotate the rendered document without reaching for the mouse.

?
Learn while you navigate

Vim takes focus automatically when the page is ready. From other app controls, press{' '} Esc to return to the document, then use j and{' '} k to move by block. Press{' '} ? for the complete key map, even when the Key panel is hidden.

); } function ReviewAnalysisTab() { const semanticDiffEnabled = useConfigValue('semanticDiffEnabled'); const callFlowEnabled = useConfigValue('callFlowEnabled'); return (
Analysis layers

Add structural context to the ordinary code diff. Each layer is independent and does no work while disabled.

configStore.set('semanticDiffEnabled', enabled)} label="Semantic changes" description="Organize added, changed, moved, and removed functions, classes, and other named code. Enabled by default." />
configStore.set('callFlowEnabled', enabled)} label="Call flow" description="Diffs for function call stacks across git commits. 22 languages supported (AST-based, built using Tree-sitter)." />
▣ Dock

Open the complete analysis from the file panel. Rows jump to the relevant code location.

⌁ File lens

File headers show compact counts and a focused popover for that file.

Call flow is syntactic.{' '} It does not use type resolution or runtime traces, so treat it as navigation context—not proof that a path executes.
); } const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => { const defaultDiffType = useConfigValue('defaultDiffType'); const reviewPanelView = useConfigValue('reviewPanelView'); const reviewAutoViewed = useConfigValue('reviewAutoViewed'); return (
Viewed files
{/* Never write `reviewAutoViewed` directly — setReviewAutoViewed also consumes the first-time notice, since an explicit toggle is proof the reviewer already found the switch. */} setReviewAutoViewed(v)} label="Auto-mark viewed" description="Mark a file viewed when you scroll past it or move on to another file. Files you un-view stay un-viewed, and files that change on refresh become un-viewed." />
Default review view
Which panel a code review opens in
{/* This is a GLOBAL preference — never hide the options because the CURRENT repo can't serve them; just say so. Without this note, picking Git status on a repo whose base ref doesn't resolve silently falls back to Tree and the setting looks broken. */} {sinceBaseUnavailable && (
Git status view isn't available in this repository (its base branch couldn't be resolved) — reviews here open in Tree. The preference still applies in repositories where it works.
)}
{/* No Commits option here: the Commits view is session-only (entered via the panel toggle) and is never the opening view. */}
Default Diff View
Which changes to show when you open a code review
{DEFAULT_DIFF_TYPE_OPTIONS.map((opt) => ( ))}
); }; const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isCompactTouchLayout = false }) => { const diffStyle = useConfigValue('diffStyle'); const diffOverflow = useConfigValue('diffOverflow'); const diffIndicators = useConfigValue('diffIndicators'); const diffLineDiffType = useConfigValue('diffLineDiffType'); const diffShowLineNumbers = useConfigValue('diffShowLineNumbers'); const diffShowBackground = useConfigValue('diffShowBackground'); const diffLineBgIntensity = useConfigValue('diffLineBgIntensity'); const diffHideWhitespace = useConfigValue('diffHideWhitespace'); const editSuggestions = useConfigValue('editSuggestions'); const diffExpandUnchanged = useConfigValue('diffExpandUnchanged'); const diffFontFamily = useConfigValue('diffFontFamily'); const diffFontSize = useConfigValue('diffFontSize'); const tokenHoverTrigger = useConfigValue('tokenHoverTrigger'); const tokenHoverDelay = useConfigValue('tokenHoverDelay'); // Load font for the preview swatch useEffect(() => { if (diffFontFamily) loadDiffFont(diffFontFamily); }, [diffFontFamily]); return ( <> {/* Experimental: edit code to author suggestions */}
Experimental
configStore.set('editSuggestions', v)} label="Edit Code to Suggest" description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor." />
{/* Hover cards (internally tokenHover*; the label is what changed, not the ids). One trigger select rather than a toggle plus a mode: `Off` is a value of the same question, so there is no unreachable enabled-but-off state to reason about. The delay stays a separate axis because "too eager" is a complaint neither the hold-modifier option nor Off answers. */}
Hover cards
Rest the pointer on a symbol in a diff to see where it is defined and who references it. Needs ripgrep and a local checkout; nothing appears when the search comes back empty. {modKeyWord}+click still opens the References panel either way.
configStore.set('tokenHoverTrigger', v)} />
How long the pointer rests before a card is requested
configStore.set('tokenHoverDelay', Number(v) as TokenHoverDelay)} disabled={tokenHoverTrigger === 'off'} />
{/* Font Family */}
Code Font
Font family for diff code lines
{diffFontFamily && (
Preview: const x = fn(42);
)}
{/* Font Size */}
Code Font Size
Font size for diff code lines
{diffFontSize || 'Auto'}
configStore.set('diffFontSize', `${e.target.value}px`)} className="flex-1 h-1.5 accent-primary cursor-pointer" /> {diffFontSize && ( )}
{/* Diff Style. The compact touch shell renders a session-only unified diff, so the control there would look dead while quietly rewriting the DESKTOP preference. Say what the phone is doing instead. */}
Diff Style
Side-by-side or inline diff view
{isCompactTouchLayout && (
This layout shows unified diffs for the session; your desktop preference is unchanged.
)}
{!isCompactTouchLayout && ( configStore.set('diffStyle', v)} /> )}
{/* Line Overflow */}
Line Overflow
How to handle long lines in diffs
configStore.set('diffOverflow', v)} />
{/* Change Indicators */}
Change Indicators
Style of +/- markers in the gutter
configStore.set('diffIndicators', v)} />
{/* Inline Diff Granularity */}
Inline Diff Granularity
Highlight granularity for inline changes
configStore.set('diffLineDiffType', v)} />
{/* Show Line Numbers */} configStore.set('diffShowLineNumbers', v)} label="Show Line Numbers" />
{/* Show Diff Background */} configStore.set('diffShowBackground', v)} label="Show Diff Background" description="Colored backgrounds on added/deleted lines" /> {/* Line Background Intensity */} {diffShowBackground && (
Line Background Intensity
How prominent the colored line backgrounds appear
configStore.set('diffLineBgIntensity', v)} />
)}
{/* Expand Unchanged Regions */} configStore.set('diffExpandUnchanged', v)} label="Expand Unchanged Regions" description="Show full file content around changes by default" />
{/* Hide Whitespace */} configStore.set('diffHideWhitespace', v)} label="Hide Whitespace" description="Ignore whitespace-only changes in diffs" />
); }; /** * Stored label config — serialized to JSON in the config store. * `blocking` controls whether the blocking/non-blocking toggle * appears in the picker when this label is selected. */ interface CCLabelConfig { label: string; display: string; blocking: boolean; // true = show toggle in picker, false = no decoration } const DEFAULT_CC_LABELS: CCLabelConfig[] = [ { label: 'suggestion', display: 'suggestion', blocking: true }, { label: 'nitpick', display: 'nit', blocking: false }, { label: 'question', display: 'question', blocking: true }, { label: 'issue', display: 'issue', blocking: true }, { label: 'praise', display: 'praise', blocking: false }, { label: 'thought', display: 'thought', blocking: false }, { label: 'note', display: 'note', blocking: false }, { label: 'todo', display: 'todo', blocking: true }, { label: 'chore', display: 'chore', blocking: true }, ]; function parseCCLabels(json: string | null): CCLabelConfig[] { if (!json) return DEFAULT_CC_LABELS; try { const parsed = JSON.parse(json); if (!Array.isArray(parsed)) return DEFAULT_CC_LABELS; return parsed.map((l: Record) => ({ label: (l.label as string) || 'custom', display: (l.display as string) || (l.label as string) || 'custom', blocking: l.blocking === true || l.blocking === 'true', })); } catch { return DEFAULT_CC_LABELS; } } const CommentsTab: React.FC = () => { const conventionalComments = useConfigValue('conventionalComments'); const labelsJson = useConfigValue('conventionalLabels'); const [labels, setLabels] = useState(() => parseCCLabels(labelsJson)); const isDefault = labelsJson === null; const save = (next: CCLabelConfig[]) => { setLabels(next); configStore.set('conventionalLabels', JSON.stringify(next)); }; const updateLabel = (index: number, updates: Partial) => { const next = [...labels]; next[index] = { ...next[index], ...updates }; save(next); }; const removeLabel = (index: number) => { save(labels.filter((_, i) => i !== index)); }; const addLabel = () => { const existing = new Set(labels.map(l => l.label)); let slug = 'custom'; let n = 2; while (existing.has(slug)) { slug = `custom-${n++}`; } save([...labels, { label: slug, display: slug, blocking: false }]); }; const resetToDefaults = () => { setLabels(DEFAULT_CC_LABELS); configStore.set('conventionalLabels', null); }; return ( <> configStore.set('conventionalComments', v)} label="Conventional Comments" description="Add structured labels to review comments" />
{/* How it works */}
How it works
When enabled, a label picker appears above the comment input when annotating code. Labels classify your feedback intent, making it clear whether a comment is a blocking issue, a trivial nitpick, or praise.
Based on the{' '} Conventional Comments {' '}spec. Comments are exported as plain text, readable on GitHub, and parseable by tooling.
{/* Example output */}
Example output
issue (blocking): This will throw if user is null — the guard was removed in the refactor.
{/* Label editor */}
Labels
Customize labels and their default severity
{!isDefault && ( )}
{/* Column headers */}
Label Blocking decorator
{labels.map((label, index) => (
{ const display = e.target.value; updateLabel(index, { display, label: display }); }} className="flex-1 px-2 py-1 bg-background/80 rounded text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 min-w-0" />
))}
{labels.length < 12 && ( )}
); }; export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, updateCheckSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => { const webmcpTools = useWebMcpToolsEnabled(); const autoUpdate = useConfigValue('autoUpdate'); const updateChecksOn = updateCheckSetting ? updateCheckSetting.on : true; const [showDialog, setShowDialog] = useState(false); const settingsWasOpenRef = useRef(false); const [themePreview, setThemePreview] = useState(false); useEffect(() => { const wasOpen = settingsWasOpenRef.current; settingsWasOpenRef.current = showDialog; if ( wasOpen && !showDialog && !themePreview && mode !== 'review' && configStore.get('vimModeEnabled') ) { requestVimDocumentFocus(); } }, [mode, showDialog, themePreview]); useEffect(() => { if (!themePreview) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setThemePreview(false); setShowDialog(true); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [themePreview]); const [activeTab, setActiveTab] = useState('general'); const gridEnabled = useConfigValue('gridEnabled'); const diagramShadow = useConfigValue('diagramShadow'); const vimModeEnabled = useConfigValue('vimModeEnabled'); const vimHudEnabled = useConfigValue('vimHudEnabled'); const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled'); const agentTerminalSide = useConfigValue('agentTerminalSide'); const [identity, setIdentity] = useState(''); const [obsidian, setObsidian] = useState({ enabled: false, vaultPath: '', folder: 'plannotator', filenameSeparator: 'space', autoSave: false, vaultBrowserEnabled: false, }); const [detectedVaults, setDetectedVaults] = useState([]); const [vaultsLoading, setVaultsLoading] = useState(false); const [bear, setBear] = useState({ enabled: false, customTags: '', tagPosition: 'append', autoSave: false }); const [octarine, setOctarine] = useState({ enabled: false, workspace: '', folder: 'plannotator', autoSave: false }); // The agent switch default depends on the surface: plan approval hands off to // the build agent, review feedback stays on the current agent. const agentSurface: AgentSwitchSurface = mode === 'review' ? 'review' : 'plan'; // Annotate with parity on gets the same document tabs as plan review. const annotateDocumentTabs = mode === 'annotate' && annotateParity; const [agent, setAgent] = useState(() => getAgentSwitchDefaults(agentSurface)); const [planSave, setPlanSave] = useState({ enabled: true, customPath: null }); const [uiPrefs, setUiPrefs] = useState({ tocEnabled: true, stickyActionsEnabled: true, planWidth: 'compact' }); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); const [agentWarning, setAgentWarning] = useState(null); const [autoCloseDelay, setAutoCloseDelayState] = useState('off'); const [defaultNotesApp, setDefaultNotesApp] = useState('ask'); const [aiProvider, setAiProvider] = useState(null); const [fileBrowserSettings, setFileBrowserSettings] = useState({ enabled: false, directories: [] }); const [newDirPath, setNewDirPath] = useState(''); // Fetch available agents for OpenCode const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null, agentSurface); const mainTabs = useMemo(() => { const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }]; t.push({ id: 'theme', label: 'Theme' }); // Annotate renders the same document viewer as plan review, so the // document-level tabs apply there too. Only rows that describe a plan // decision (plan snapshots, plan-arrival auto-save, plan-time hooks, // post-approval permission mode / agent switch) stay plan-only. if (mode === 'plan' || annotateDocumentTabs) { t.push({ id: 'display', label: 'Display' }); t.push({ id: 'saving', label: 'Saving' }); t.push({ id: 'labels', label: 'Labels' }); } if (mode === 'review') { t.push({ id: 'git', label: 'Git' }); t.push({ id: 'display', label: 'Editor' }); t.push({ id: 'analysis', label: 'Analysis' }); t.push({ id: 'comments', label: 'Comments' }); if (aiProviders.length > 0) { t.push({ id: 'ai', label: 'AI' }); } } if (mode !== 'review') { t.push({ id: 'vim', label: 'Vim' }); } t.push({ id: 'shortcuts', label: 'Shortcuts' }); if (mode === 'plan') { t.push({ id: 'hooks', label: 'Hooks' }); } return t; }, [mode, annotateDocumentTabs, aiProviders.length]); const integrationTabs: { id: SettingsTab; label: string }[] = [ { id: 'files', label: 'Files' }, ...(mode === 'plan' || annotateDocumentTabs ? [{ id: 'obsidian' as SettingsTab, label: 'Obsidian' }, { id: 'bear' as SettingsTab, label: 'Bear' }, { id: 'octarine' as SettingsTab, label: 'Octarine' }] : []), ]; const obsidianDefaultSaveAvailable = obsidian.enabled && getEffectiveVaultPath(obsidian).trim().length > 0; const bearDefaultSaveAvailable = bear.enabled; const octarineDefaultSaveAvailable = octarine.enabled && octarine.workspace.trim().length > 0; // Sync external open state useEffect(() => { if (externalOpen) { setShowDialog(true); onExternalClose?.(); } }, [externalOpen, onExternalClose]); useEffect(() => { if (showDialog) { setIdentity(getIdentity()) setObsidian(getObsidianSettings()); setBear(getBearSettings()); setOctarine(getOctarineSettings()); setAgent(getAgentSwitchSettings(agentSurface)); setPlanSave(getPlanSaveSettings()); setUiPrefs(getUIPreferences()); setPermissionMode(getPermissionModeSettings().mode); setAutoCloseDelayState(getAutoCloseDelay()); setDefaultNotesApp(getDefaultNotesApp()); const aiSettings = getAIProviderSettings(); setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId); setFileBrowserSettings(getFileBrowserSettings()); // Validate agent setting when dialog opens if (origin === 'opencode') { setAgentWarning(getAgentWarning()); } } }, [showDialog, availableAgents, origin, getAgentWarning, aiProviders.length]); useEffect(() => { if (!showDialog) return; const defaultSaveAvailable = defaultNotesApp === 'ask' || defaultNotesApp === 'download' || (defaultNotesApp === 'obsidian' && obsidianDefaultSaveAvailable) || (defaultNotesApp === 'bear' && bearDefaultSaveAvailable) || (defaultNotesApp === 'octarine' && octarineDefaultSaveAvailable); if (!defaultSaveAvailable) { setDefaultNotesApp('ask'); saveDefaultNotesApp('ask'); } }, [ showDialog, defaultNotesApp, obsidianDefaultSaveAvailable, bearDefaultSaveAvailable, octarineDefaultSaveAvailable, ]); // Fetch detected vaults when Obsidian is enabled useEffect(() => { if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) { setVaultsLoading(true); const detect = onDetectObsidianVaults ?? (() => fetch('/api/obsidian/vaults').then(res => res.json()).then((data: { vaults: string[] }) => data.vaults || [])); detect() .then((vaults: string[]) => { setDetectedVaults(vaults || []); // Auto-select first vault if none set if (vaults?.length > 0 && !obsidian.vaultPath) { handleObsidianChange({ vaultPath: vaults[0] }); } }) .catch(() => setDetectedVaults([])) .finally(() => setVaultsLoading(false)); } }, [obsidian.enabled]); const handleFileBrowserChange = (updates: Partial) => { const newSettings = { ...fileBrowserSettings, ...updates }; setFileBrowserSettings(newSettings); saveFileBrowserSettings(newSettings); if (onUIPreferencesChange) onUIPreferencesChange({ ...uiPrefs }); }; const addDirectory = () => { const trimmed = newDirPath.trim(); if (trimmed && !fileBrowserSettings.directories.includes(trimmed)) { handleFileBrowserChange({ directories: [...fileBrowserSettings.directories, trimmed], }); } setNewDirPath(''); }; const handleObsidianChange = (updates: Partial) => { const newSettings = { ...obsidian, ...updates }; setObsidian(newSettings); saveObsidianSettings(newSettings); }; const handleBearChange = (updates: Partial) => { const newSettings = { ...bear, ...updates }; setBear(newSettings); saveBearSettings(newSettings); }; const handleOctarineChange = (updates: Partial) => { const newSettings = { ...octarine, ...updates }; setOctarine(newSettings); saveOctarineSettings(newSettings); }; const handleAgentChange = (switchTo: AgentSwitchSettings['switchTo'], customName?: string) => { const newSettings = { switchTo, customName: customName ?? agent.customName }; setAgent(newSettings); saveAgentSwitchSettings(newSettings); }; const handlePlanSaveChange = (updates: Partial) => { const newSettings = { ...planSave, ...updates }; setPlanSave(newSettings); savePlanSaveSettings(newSettings); }; const handleUIPrefsChange = (updates: Partial) => { const newPrefs = { ...uiPrefs, ...updates }; setUiPrefs(newPrefs); saveUIPreferences(newPrefs); onUIPreferencesChange?.(newPrefs); }; const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { setDefaultNotesApp(app); saveDefaultNotesApp(app); }; // Server write-back is handled automatically by configStore.set() (debounced POST /api/config) const handleRegenerateIdentity = () => { const oldIdentity = identity; const newIdentity = regenerateIdentity(); setIdentity(newIdentity); onIdentityChange?.(oldIdentity, newIdentity); }; const handleIdentitySave = (newName: string) => { const trimmed = newName.trim(); if (!trimmed || trimmed === identity) return; const oldIdentity = identity; const saved = setCustomIdentity(trimmed); setIdentity(saved); onIdentityChange?.(oldIdentity, saved); }; const handleUseGitName = () => { if (!gitUser) return; handleIdentitySave(gitUser); }; // When a host owns identity (e.g. a logged-in user whose author is server-stamped), // hide the rename/regenerate controls so a local name can't diverge from it. // Default (Plannotator cookie identity) is editable, so this is true and the // controls render exactly as before. const identityEditable = isIdentityEditable(); return ( <> {showDialog && !themePreview && createPortal(
setShowDialog(false)} >
e.stopPropagation()} onKeyDown={(event) => { if (event.key !== 'Escape' || event.defaultPrevented) return; event.preventDefault(); event.stopPropagation(); setShowDialog(false); }} > {taterMode && }

Settings

{/* Mobile: horizontal tab bar */} {/* Desktop: sidebar */} {/* Content — scrollable */}
{/* === GENERAL TAB === */} {activeTab === 'general' && ( <> {/* Identity */}
Your Identity
Used when sharing annotations with others
{identityEditable ? ( handleIdentitySave(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleIdentitySave((e.target as HTMLInputElement).value); (e.target as HTMLInputElement).blur(); } }} className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate border border-transparent focus:border-primary/50 focus:outline-none transition-colors" placeholder="Enter your name..." /> ) : (
{identity}
)} {identityEditable && gitUser && ( )} {identityEditable && ( )}
{/* Update notices: on by default. Writes updateCheck to ~/.plannotator/config.json; off makes no request to GitHub. */} {updateCheckSetting && ( <>
updateCheckSetting.onChange(v)} disabled={updateCheckSetting.env !== undefined} label="Tell me when a new version is out" description={ updateCheckSetting.env !== undefined ? 'Set by PLANNOTATOR_UPDATE_CHECK in your environment.' : 'This computer asks GitHub once a day, never your browser. Off makes no request at all.' } /> )} {/* Auto-update (#1634): opt-in, default off. Writes autoUpdate to ~/.plannotator/config.json. It needs update checks, so it is locked while they are off. */} {autoUpdateSetting && ( <>
configStore.set('autoUpdate', v)} disabled={autoUpdateSetting.env !== undefined || !updateChecksOn} label="Keep Plannotator up to date" description={ autoUpdateSetting.env !== undefined ? 'Set by PLANNOTATOR_AUTO_UPDATE in your environment.' : !updateChecksOn ? 'Needs update checks (above).' : 'Installs new releases in the background, at most once a day, never while another review is open.' } /> )} {/* The `plannotator` agent tool switch: only where the server reports a tool host (Claude Code mod, Pi, OpenCode 2). Writes agentTool to config.json; applies to the next session. */} {agentToolSetting && onAgentToolChange && ( <>
)} {/* Agent tools (WebMCP). Shown only when the browser exposes document.modelContext; off unregisters the tools for this browser. Cookie-only and idle at its default: no cookie exists until the user opts out. */} {webmcpAvailable && ( <>
Agent tools
Let your browser's agent read this document and leave comments. Approving and sending feedback stay yours.
)} {/* Permission Mode (Claude Code only) */} {origin === 'claude-code' && mode === 'plan' && ( <>
Permission Mode
Automation level after plan approval
{PERMISSION_MODE_OPTIONS.find(o => o.value === permissionMode)?.description}
)} {/* Agent Switching (OpenCode only). Applied on plan approval only; annotate decisions never switch agents. */} {origin === 'opencode' && !annotateDocumentTabs && ( <>
Agent Switching
Which agent to switch to after plan approval
{agentWarning && (
{agentWarning}
)} {agent.switchTo === 'custom' && ( { const customName = e.target.value; handleAgentChange('custom', customName); if (customName && availableAgents.length > 0) { if (!validateAgent(customName)) { setAgentWarning(`Agent "${customName}" not found in OpenCode. It may cause errors.`); } else { setAgentWarning(null); } } else { setAgentWarning(null); } }} placeholder="Enter agent name..." className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 placeholder:text-muted-foreground/50" /> )}
{agent.switchTo === 'custom' && agent.customName ? `Switch to "${agent.customName}" agent after approval` : agent.switchTo === 'disabled' ? 'Stay on current agent after approval' : `Switch to ${agent.switchTo} agent after approval`}
)} {/* Agent TUI position. The same control lives in the terminal's own Display popover, but that one is unreachable once the position is Hidden — this is the way back. Both write the same config value. Gated on the terminal actually being available in this session, so a remote or runtime-less annotate never offers a dead control. */} {mode === 'annotate' && agentTerminalAvailable && ( <>
Agent TUI Position
Which side the agent terminal docks on, or Hidden to keep it out of the layout
configStore.set('agentTerminalSide', v)} /> {agentTerminalSide === 'hidden' && (
You can still open it for this session from the sidebar rail.
)}
)}
{/* Auto-close Tab */}
Auto-close Tab
{AUTO_CLOSE_OPTIONS.find(o => o.value === autoCloseDelay)?.description}
)} {/* === THEME TAB === */} {activeTab === 'theme' && { setShowDialog(false); setThemePreview(true); }} />} {/* === GIT TAB === */} {activeTab === 'git' && mode === 'review' && ( )} {/* === DISPLAY TAB === */} {activeTab === 'display' && mode === 'review' && ( )} {activeTab === 'analysis' && mode === 'review' && ( )} {activeTab === 'display' && mode !== 'review' && ( <> {/* Auto-open Sidebar */}
Auto-open Sidebar
Open sidebar with Table of Contents on load
{/* Sticky Actions */}
Sticky Actions
Keep action buttons visible while scrolling
{/* Grid Background */}
Grid Background
Show the plan as a floating card on a grid
{/* Diagram Shadow */}
Diagram Shadow
Drop shadow under diagram nodes (100 = Mermaid's own)
{DIAGRAM_SHADOW_OPTIONS.map((value) => ( ))}
{/* Plan Width */}
{mode === 'annotate' ? 'Document Width' : 'Plan Width'}
{mode === 'annotate' ? 'Maximum width of the document card' : 'Maximum width of the plan card'}
{PLAN_WIDTH_OPTIONS.map((opt) => ( ))}
{/* Abstract layout preview — exaggerated proportions for visual clarity */} {(() => { const active = PLAN_WIDTH_OPTIONS.find(o => o.id === uiPrefs.planWidth) ?? PLAN_WIDTH_OPTIONS[0]; // Exaggerated proportions so the width difference is visually obvious in the small preview const sidebarPct = 14; const panelPct = 14; const cardPctMap: Record = { compact: 48, default: 70, wide: 94 }; const cardPct = cardPctMap[active.id]; return (
{/* Simulated header bar */}
{/* Three-column layout */}
{/* Sidebar */}
{/* Plan card — width animates */}
{/* Heading */}
{/* Prose lines */}
{/* Code block */}
{/* More prose */}
{/* Annotation panel */}
{active.px}px — {active.hint}
); })()}
{/* Tater Mode */}
Tater Mode
)} {/* === SAVING TAB === */} {activeTab === 'saving' && ( <> {/* Plan Saving — decision snapshots in plans/ are written on plan approve/deny only. */} {mode === 'plan' && (<>
Save Plans
Auto-save plans to the default data directory
{planSave.enabled && (
handlePlanSaveChange({ customPath: e.target.value || null })} placeholder="Leave empty for default" className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
Leave empty to use default location
)}
)} {/* Default Notes App */}
Default Save Action
Used for keyboard shortcut ({modKey}+S)
{defaultNotesApp === 'ask' ? 'Opens Export dialog with Notes tab' : defaultNotesApp === 'download' ? `${modKey}+S downloads the annotations file` : `${modKey}+S saves directly to ${{ obsidian: 'Obsidian', bear: 'Bear', octarine: 'Octarine' }[defaultNotesApp] ?? defaultNotesApp}`}
{/* Integration links */}
Integrations
)} {/* === LABELS TAB === */} {activeTab === 'labels' && } {/* === VIM TAB === */} {activeTab === 'vim' && mode !== 'review' && ( configStore.set('vimModeEnabled', enabled)} onVimHudChange={(enabled) => configStore.set('vimHudEnabled', enabled)} onVimHudKeyPanelChange={(enabled) => configStore.set('vimHudKeyPanelEnabled', enabled)} /> )} {/* === SHORTCUTS TAB === */} {activeTab === 'shortcuts' && ( )} {/* === COMMENTS TAB === */} {activeTab === 'comments' && ( )} {/* === AI TAB === */} {activeTab === 'ai' && ( )} {/* === FILES TAB === */} {activeTab === 'files' && ( <>
File Browser
Your project files are shown automatically. Add extra directories below.
{fileBrowserSettings.enabled && ( <>
{/* Directory list */} {fileBrowserSettings.directories.length > 0 && (
{fileBrowserSettings.directories.map((dir) => (
{dir}
))}
)} {/* Add directory */}
setNewDirPath(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addDirectory(); }} placeholder="/path/to/directory" className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
Add directories outside your project that contain markdown files.
)} )} {/* === HOOKS TAB === */} {activeTab === 'hooks' && } {/* === OBSIDIAN TAB === */} {activeTab === 'obsidian' && ( <>
Obsidian Integration
{mode === 'annotate' ? 'Save documents to your vault from the Options menu. When on, plan review also saves each approved plan here.' : 'Auto-save approved plans to your vault'}
{obsidian.enabled && ( <>
{vaultsLoading ? (
Detecting...
) : detectedVaults.length > 0 ? ( <> {obsidian.vaultPath === CUSTOM_PATH_SENTINEL && ( handleObsidianChange({ customPath: e.target.value })} placeholder="/path/to/vault" className="w-full mt-2 px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" /> )} ) : ( handleObsidianChange({ vaultPath: e.target.value })} placeholder="/path/to/vault" className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" /> )}
handleObsidianChange({ folder: e.target.value })} placeholder="plannotator" className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
handleObsidianChange({ filenameFormat: e.target.value || undefined })} placeholder={DEFAULT_FILENAME_FORMAT} className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
Variables: {'{title}'} {'{YYYY}'} {'{MM}'} {'{DD}'} {'{Mon}'} {'{D}'} {'{HH}'} {'{h}'} {'{hh}'} {'{mm}'} {'{ss}'} {'{ampm}'}
Preview: {(() => { const fmt = obsidian.filenameFormat?.trim() || DEFAULT_FILENAME_FORMAT; const now = new Date(); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const h24 = now.getHours(); const h12 = h24 % 12 || 12; const vars: Record = { title: 'My Plan Title', YYYY: String(now.getFullYear()), MM: String(now.getMonth()+1).padStart(2,'0'), DD: String(now.getDate()).padStart(2,'0'), Mon: months[now.getMonth()], D: String(now.getDate()), HH: String(h24).padStart(2,'0'), h: String(h12), hh: String(h12).padStart(2,'0'), mm: String(now.getMinutes()).padStart(2,'0'), ss: String(now.getSeconds()).padStart(2,'0'), ampm: h24 >= 12 ? 'pm' : 'am', }; let preview = fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md'; if (obsidian.filenameSeparator === 'dash') preview = preview.replace(/ /g, '-'); else if (obsidian.filenameSeparator === 'underscore') preview = preview.replace(/ /g, '_'); return preview; })()}
Replaces spaces in the generated filename. Useful when working with CLI tools in your vault.
{mode === 'annotate' ? 'Saved to' : 'Plans saved to'}: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL ? (obsidian.customPath || '...') : (obsidian.vaultPath || '...')}/{obsidian.folder || 'plannotator'}/
{`---
created: ${new Date().toISOString().slice(0, 19)}Z
source: plannotator
tags: [${mode === 'annotate' ? 'plannotator' : 'plan'}, ...]
---`}
                            
{mode === 'plan' && (<>
Auto-save on Plan Arrival
Automatically save to Obsidian when a plan loads, before you approve or deny
)}
Vault Browser
Browse and annotate vault files from the sidebar
)} )} {/* === BEAR TAB === */} {activeTab === 'bear' && ( <>
Bear Notes
{mode === 'annotate' ? 'Save documents to Bear from the Options menu. When on, plan review also saves each approved plan here.' : 'Auto-save approved plans to Bear'}
{bear.enabled && (
handleBearChange({ customTags: e.target.value })} onBlur={(e) => handleBearChange({ customTags: normalizeTags(e.target.value) })} placeholder="plan, work" className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
Comma-separated, kebab-case. Leave empty for auto-generated tags.
{mode === 'plan' && (<>
Auto-save on Plan Arrival
Automatically save to Bear when a plan loads, before you approve or deny
)}
)} )} {/* === OCTARINE TAB === */} {activeTab === 'octarine' && ( <>
Octarine
{mode === 'annotate' ? 'Save documents to Octarine from the Options menu. When on, plan review also saves each approved plan here.' : 'Auto-save approved plans to Octarine'}
{octarine.enabled && (
handleOctarineChange({ workspace: e.target.value })} placeholder="My Workspace" className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
The Octarine workspace name to save plans into.
handleOctarineChange({ folder: e.target.value })} placeholder="plannotator" className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" />
Subfolder within the workspace for saved plans.
{mode === 'annotate' ? 'Saved to' : 'Plans saved to'}: {octarine.workspace || '...'} / {octarine.folder || 'plannotator'}/
{mode === 'plan' && (<>
Auto-save on Plan Arrival
Automatically save to Octarine when a plan loads, before you approve or deny
)}
)} )}
, document.body )} {themePreview && createPortal(
e.stopPropagation()} >
Theme Preview
, document.body )} ); };