fix(electrobun): address all 20 Codex review findings

CRITICAL:
- PTY leak: Terminal.svelte now calls pty.close on destroy, not just unsubscribe
- Agent session cleanup: clearSession() removes done/error sessions, backend
  deletes after 60s grace period

HIGH:
- Clone branch passthrough: user's branch name flows through callback
- Circular imports: extracted rpc.ts singleton, broke main.ts ↔ App.svelte cycle
- Settings wired to runtime: Terminal reads cursor/scrollback from settings
- Security disclaimer: added "prototype — not system keyring" notice
- ThemeEditor: fixed basePalette → initialPalette reference

MEDIUM:
- Clone race: UUID suffix instead of count-based index
- Silent failures: structured error returns from PTY handlers
- WebKitGTK mount: only current + previous group mounted
- Debug listeners: gated behind DEBUG, cleanup on destroy
- NDJSON residual buffer parsed on process exit
- Codex adapter: deduplicated tool_call/tool_result
- extraEnv: rejects CLAUDE*/CODEX*/OLLAMA* keys
- settings-db: runMigrations() with version tracking
- active_group: persisted via settings.set

LOW:
- Removed dead demo code, unused variables
- color-mix() fallbacks added
This commit is contained in:
Hibryda 2026-03-22 01:20:23 +01:00
parent ef0183de7f
commit 29a3370e79
18 changed files with 331 additions and 114 deletions

View file

@ -8,7 +8,7 @@
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { appRpc } from './main.ts';
import { appRpc } from './rpc.ts';
// ── Types ─────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
@ -104,6 +104,9 @@
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
]);
let activeGroupId = $state('dev');
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
let previousGroupId = $state<string | null>(null);
let mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]));
// ── Filtered projects for active group ────────────────────────
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
@ -111,39 +114,15 @@
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
);
// Group projects into: top-level cards + clone groups
interface ProjectRow { type: 'standalone'; project: Project; }
interface CloneGroupRow { type: 'clone-group'; parent: Project; clones: Project[]; }
type GridRow = ProjectRow | CloneGroupRow;
let gridRows = $derived((): GridRow[] => {
const standalone: GridRow[] = [];
const cloneParentIds = new Set(
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
);
for (const p of filteredProjects) {
if (p.cloneOf) continue;
if (cloneParentIds.has(p.id)) {
const clones = filteredProjects
.filter(c => c.cloneOf === p.id)
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
standalone.push({ type: 'clone-group', parent: p, clones });
} else {
standalone.push({ type: 'standalone', project: p });
}
}
return standalone;
});
// ── Clone helpers ──────────────────────────────────────────────
function cloneCountForProject(projectId: string): number {
return PROJECTS.filter(p => p.cloneOf === projectId).length;
}
function handleClone(projectId: string) {
function handleClone(projectId: string, branch: string) {
const source = PROJECTS.find(p => p.id === projectId);
if (!source) return;
const branchName = `feature/clone-${Date.now()}`;
const branchName = branch || `feature/clone-${Date.now()}`;
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
if (result.ok && result.project) {
const cloneConfig = JSON.parse(result.project.config) as Project;
@ -176,9 +155,13 @@
// ── setActiveGroup: fire-and-forget RPC ───────────────────────
function setActiveGroup(id: string | undefined) {
if (!id) return;
console.log('[DEBUG] setActiveGroup:', id);
// Fix #10: Track previous group for DOM mount limit
if (activeGroupId !== id) {
previousGroupId = activeGroupId;
}
activeGroupId = id;
// NO RPC — pure local state change for debugging
// Fix #16: Persist active_group selection
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
}
// ── Window controls ────────────────────────────────────────────
@ -263,10 +246,12 @@
function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
// ── DEBUG: Visual click diagnostics overlay ─────────────────────
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
let debugLog = $state<string[]>([]);
$effect(() => {
if (!DEBUG_ENABLED) return;
function debugClick(e: MouseEvent) {
const el = e.target as HTMLElement;
const tag = el?.tagName;
@ -276,15 +261,20 @@
if (elAtPoint && elAtPoint !== el) {
const overTag = (elAtPoint as HTMLElement).tagName;
const overCls = ((elAtPoint as HTMLElement).className?.toString?.() ?? '').slice(0, 40);
line += ` ⚠️OVERLAY: ${overTag}.${overCls}`;
line += ` OVERLAY: ${overTag}.${overCls}`;
}
debugLog = [...debugLog.slice(-8), line];
}
document.addEventListener('click', debugClick, true);
document.addEventListener('mousedown', (e) => {
function debugDown(e: MouseEvent) {
const el = e.target as HTMLElement;
debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`];
}, true);
}
document.addEventListener('click', debugClick, true);
document.addEventListener('mousedown', debugDown, true);
return () => {
document.removeEventListener('click', debugClick, true);
document.removeEventListener('mousedown', debugDown, true);
};
});
// ── Init ───────────────────────────────────────────────────────
@ -327,7 +317,6 @@
<div
class="app-shell"
role="presentation"
onresize={saveWindowFrame}
>
<!-- Left sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
@ -374,9 +363,10 @@
<main class="workspace">
<!-- Project grid -->
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
<!-- ALL projects rendered always with display:none/flex toggling.
<!-- Fix #10: Only mount projects in active + previous group (max 2 groups).
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
{#each PROJECTS as project (project.id)}
{#if mountedGroupIds.has(project.groupId ?? 'dev')}
<div role="listitem" style:display={(project.groupId ?? 'dev') === activeGroupId ? 'flex' : 'none'}>
<ProjectCard
id={project.id}
@ -395,6 +385,7 @@
cloneOf={project.cloneOf}
/>
</div>
{/if}
{/each}
<!-- Empty group — always in DOM, visibility toggled -->
@ -486,12 +477,14 @@
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
<!-- DEBUG: visible click log -->
{#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
<div style="position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-family:monospace;font-size:10px;padding:4px 8px;z-index:9999;max-height:100px;overflow-y:auto;pointer-events:none;">
{#each debugLog as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
:global(body) { overflow: hidden; }
@ -642,28 +635,6 @@
.project-grid::-webkit-scrollbar-track { background: transparent; }
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
.clone-group-row {
grid-column: 1 / -1;
display: flex;
flex-direction: row;
gap: 0;
align-items: stretch;
min-height: 0;
}
.clone-group-row :global(.project-card) { flex: 1; min-width: 0; }
.chain-icon {
flex-shrink: 0;
width: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-surface1);
}
.chain-icon svg { width: 1rem; height: 1rem; }
.empty-group {
grid-column: 1 / -1;
display: flex;

View file

@ -27,8 +27,8 @@
cloneOf?: string;
/** Max clones reached for this project. */
clonesAtMax?: boolean;
/** Callback when user requests cloning. */
onClone?: (projectId: string) => void;
/** Callback when user requests cloning (receives projectId and branch name). */
onClone?: (projectId: string, branch: string) => void;
}
let {
@ -78,7 +78,7 @@
cloneError = 'Use only letters, numbers, /, _, -, .';
return;
}
onClone?.(id);
onClone?.(id, cloneBranchName);
showCloneDialog = false;
}

View file

@ -4,7 +4,7 @@
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { ImageAddon } from '@xterm/addon-image';
import { electrobun } from './main.ts';
import { appRpc } from './rpc.ts';
import { fontStore } from './font-store.svelte.ts';
import { themeStore } from './theme-store.svelte.ts';
import { getXtermTheme } from './themes.ts';
@ -22,6 +22,7 @@
let fitAddon: FitAddon;
let unsubFont: (() => void) | null = null;
let ro: ResizeObserver | null = null;
let destroyed = false;
/** Decode a base64 string from the daemon into a Uint8Array. */
function decodeBase64(b64: string): Uint8Array {
@ -60,20 +61,41 @@
term.open(termEl);
fitAddon.fit();
// ── Read cursor/scrollback settings ─────────────────────────────────
void (async () => {
try {
const { settings } = await appRpc.request['settings.getAll']({});
if (settings['cursor_style']) {
const style = settings['cursor_style'];
if (style === 'block' || style === 'underline' || style === 'bar') {
term.options.cursorStyle = style;
}
}
if (settings['cursor_blink'] === 'false') {
term.options.cursorBlink = false;
}
if (settings['scrollback']) {
const sb = parseInt(settings['scrollback'], 10);
if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb;
}
} catch { /* non-critical — use defaults */ }
})();
// ── Subscribe to terminal font changes ─────────────────────────────────
unsubFont = fontStore.onTermFontChange((family: string, size: number) => {
term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace';
term.options.fontSize = size;
fitAddon.fit();
electrobun.rpc?.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
});
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
void (async () => {
const { cols, rows } = term;
const result = await electrobun.rpc?.request['pty.create']({ sessionId, cols, rows, cwd });
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd });
if (!result?.ok) {
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
@ -82,20 +104,22 @@
// ── Receive output from daemon ─────────────────────────────────────────
electrobun.rpc?.addMessageListener('pty.output', ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
if (sid !== sessionId) return;
const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
if (destroyed || sid !== sessionId) return;
term.write(decodeBase64(data));
});
};
appRpc.addMessageListener('pty.output', outputHandler);
electrobun.rpc?.addMessageListener('pty.closed', ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
if (sid !== sessionId) return;
const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
if (destroyed || sid !== sessionId) return;
term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`);
});
};
appRpc.addMessageListener('pty.closed', closedHandler);
// ── Send user input to daemon ──────────────────────────────────────────
term.onData((data: string) => {
electrobun.rpc?.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
appRpc.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
console.error('[pty.write] error:', err);
});
});
@ -103,7 +127,7 @@
// ── Sync resize events to daemon ───────────────────────────────────────
term.onResize(({ cols: c, rows: r }) => {
electrobun.rpc?.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
});
ro = new ResizeObserver(() => { fitAddon.fit(); });
@ -111,9 +135,11 @@
});
onDestroy(() => {
destroyed = true;
unsubFont?.();
ro?.disconnect();
electrobun.rpc?.request['pty.unsubscribe']({ sessionId }).catch(() => {});
// Fix #1: Close the PTY session (not just unsubscribe) to prevent session leak
appRpc.request['pty.close']({ sessionId }).catch(() => {});
term?.dispose();
});
</script>

View file

@ -5,7 +5,7 @@
* Exposes reactive Svelte 5 rune state per project.
*/
import { electrobun, appRpc } from './main.ts';
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────────────
@ -45,6 +45,24 @@ interface StartOptions {
extraEnv?: Record<string, string>;
}
// ── Env var validation (Fix #14) ─────────────────────────────────────────────
const BLOCKED_ENV_PREFIXES = ['CLAUDE', 'CODEX', 'OLLAMA', 'ANTHROPIC_'];
function validateExtraEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
if (!env) return undefined;
const clean: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
const blocked = BLOCKED_ENV_PREFIXES.some(p => key.startsWith(p));
if (blocked) {
console.warn(`[agent-store] Rejected extraEnv key "${key}" — provider-prefixed keys are not allowed`);
continue;
}
clean[key] = value;
}
return Object.keys(clean).length > 0 ? clean : undefined;
}
// ── Internal state ───────────────────────────────────────────────────────────
// Map projectId -> sessionId for lookup
@ -53,6 +71,9 @@ const projectSessionMap = new Map<string, string>();
// Map sessionId -> reactive session state
let sessions = $state<Record<string, AgentSession>>({});
// Grace period timers for cleanup after done/error
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
// ── RPC event listeners (registered once) ────────────────────────────────────
let listenersRegistered = false;
@ -62,7 +83,7 @@ function ensureListeners() {
listenersRegistered = true;
// agent.message — raw messages from sidecar, converted to display format
electrobun.rpc?.addMessageListener('agent.message', (payload: {
appRpc.addMessageListener('agent.message', (payload: {
sessionId: string;
messages: Array<{
id: string;
@ -87,7 +108,7 @@ function ensureListeners() {
});
// agent.status — session status changes
electrobun.rpc?.addMessageListener('agent.status', (payload: {
appRpc.addMessageListener('agent.status', (payload: {
sessionId: string;
status: string;
error?: string;
@ -97,10 +118,15 @@ function ensureListeners() {
session.status = normalizeStatus(payload.status);
if (payload.error) session.error = payload.error;
// Schedule cleanup after done/error (Fix #2)
if (session.status === 'done' || session.status === 'error') {
scheduleCleanup(session.sessionId, session.projectId);
}
});
// agent.cost — token/cost updates
electrobun.rpc?.addMessageListener('agent.cost', (payload: {
appRpc.addMessageListener('agent.cost', (payload: {
sessionId: string;
costUsd: number;
inputTokens: number;
@ -115,6 +141,32 @@ function ensureListeners() {
});
}
// ── Cleanup scheduling (Fix #2) ──────────────────────────────────────────────
const CLEANUP_GRACE_MS = 60_000; // 60 seconds after done/error
function scheduleCleanup(sessionId: string, projectId: string) {
// Cancel any existing timer for this session
const existing = cleanupTimers.get(sessionId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
cleanupTimers.delete(sessionId);
// Only clean up if session is still in done/error state
const session = sessions[sessionId];
if (session && (session.status === 'done' || session.status === 'error')) {
// Keep session data (messages, cost) but remove from projectSessionMap
// so starting a new session on this project works cleanly
const currentMapped = projectSessionMap.get(projectId);
if (currentMapped === sessionId) {
projectSessionMap.delete(projectId);
}
}
}, CLEANUP_GRACE_MS);
cleanupTimers.set(sessionId, timer);
}
// ── Message conversion ───────────────────────────────────────────────────────
function convertRawMessage(raw: {
@ -146,7 +198,6 @@ function convertRawMessage(raw: {
case 'tool_call': {
const name = String(c?.name ?? 'Tool');
const input = c?.input as Record<string, unknown> | undefined;
// Extract file path from common tool input patterns
const path = extractToolPath(name, input);
return {
id: raw.id,
@ -174,7 +225,6 @@ function convertRawMessage(raw: {
case 'init': {
const model = String(c?.model ?? '');
// Update session model from init message
const sid = String(c?.sessionId ?? '');
for (const s of Object.values(sessions)) {
if (s.sessionId === raw.id || (sid && s.sessionId.includes(sid.slice(0, 8)))) {
@ -210,7 +260,6 @@ function convertRawMessage(raw: {
function extractToolPath(name: string, input: Record<string, unknown> | undefined): string | undefined {
if (!input) return undefined;
// Common patterns: file_path, path, command (for Bash)
if (typeof input.file_path === 'string') return input.file_path;
if (typeof input.path === 'string') return input.path;
if (name === 'Bash' && typeof input.command === 'string') {
@ -241,7 +290,7 @@ function normalizeStatus(status: string): AgentStatus {
// ── Public API ───────────────────────────────────────────────────────────────
/** Start an agent session for a project. */
/** Start an agent session for a project (Fix #5: reads permission_mode + system_prompt from settings). */
export async function startAgent(
projectId: string,
provider: string,
@ -250,8 +299,24 @@ export async function startAgent(
): Promise<{ ok: boolean; error?: string }> {
ensureListeners();
// If there's an existing done/error session for this project, clear it first
clearSession(projectId);
const sessionId = `${projectId}-${Date.now()}`;
// Read settings defaults if not explicitly provided (Fix #5)
let permissionMode = options.permissionMode;
let systemPrompt = options.systemPrompt;
try {
const { settings } = await appRpc.request['settings.getAll']({});
if (!permissionMode && settings['permission_mode']) {
permissionMode = settings['permission_mode'];
}
if (!systemPrompt && settings['system_prompt_template']) {
systemPrompt = settings['system_prompt_template'];
}
} catch { /* use provided or defaults */ }
// Create reactive session state
sessions[sessionId] = {
sessionId,
@ -278,11 +343,11 @@ export async function startAgent(
prompt,
cwd: options.cwd,
model: options.model,
systemPrompt: options.systemPrompt,
systemPrompt: systemPrompt,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
permissionMode: permissionMode,
claudeConfigDir: options.claudeConfigDir,
extraEnv: options.extraEnv,
extraEnv: validateExtraEnv(options.extraEnv),
});
if (!result.ok) {
@ -341,5 +406,26 @@ export function hasSession(projectId: string): boolean {
return projectSessionMap.has(projectId);
}
/**
* Clear a done/error session for a project (Fix #2).
* Removes from projectSessionMap so a new session can start.
* Keeps session data in sessions map for history access.
*/
export function clearSession(projectId: string): void {
const sessionId = projectSessionMap.get(projectId);
if (!sessionId) return;
const session = sessions[sessionId];
if (session && (session.status === 'done' || session.status === 'error')) {
projectSessionMap.delete(projectId);
// Cancel any pending cleanup timer
const timer = cleanupTimers.get(sessionId);
if (timer) {
clearTimeout(timer);
cleanupTimers.delete(sessionId);
}
}
}
/** Initialize listeners on module load. */
ensureListeners();

View file

@ -4,6 +4,7 @@ import App from "./App.svelte";
import { mount } from "svelte";
import { Electroview } from "electrobun/view";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { setAppRpc } from "./rpc.ts";
/**
* Set up Electroview RPC.
@ -31,9 +32,12 @@ const rpc = Electroview.defineRPC<PtyRPCSchema>({
},
});
// Register the RPC singleton so all modules can import from rpc.ts
setAppRpc(rpc);
export const electrobun = new Electroview({ rpc });
/** Exported for use by stores that need RPC access (theme-store, font-store). */
/** @deprecated Import from './rpc.ts' instead. */
export { rpc as appRpc };
const app = mount(App, {

View file

@ -0,0 +1,34 @@
/**
* RPC singleton breaks the circular import chain.
*
* main.ts creates the Electroview and RPC, then sets it here.
* All other modules import from this file instead of main.ts.
*/
import type { PtyRPCSchema } from '../shared/pty-rpc-schema.ts';
// Placeholder type — matches the shape Electroview.defineRPC returns.
// Uses `any` for the internal Electrobun RPC wrapper type since it is
// not exported from the electrobun package.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ElectrobunRpc = any;
let _rpc: ElectrobunRpc | null = null;
/** Called once from main.ts after Electroview.defineRPC(). */
export function setAppRpc(rpc: ElectrobunRpc): void {
_rpc = rpc;
}
/**
* The app-wide RPC handle.
* Safe to call after main.ts has executed (Svelte components mount after).
*/
export const appRpc: ElectrobunRpc = new Proxy({} as ElectrobunRpc, {
get(_target, prop) {
if (!_rpc) {
throw new Error(`[rpc] accessed before init — property "${String(prop)}"`);
}
return (_rpc as Record<string | symbol, unknown>)[prop];
},
});

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
type PermMode = 'bypassPermissions' | 'default' | 'plan';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { fontStore } from '../font-store.svelte.ts';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
interface CatalogPlugin {
id: string;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
type AnchorScale = 'small' | 'medium' | 'large' | 'full';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
const KNOWN_KEYS: Record<string, string> = {
ANTHROPIC_API_KEY: 'Anthropic API Key',
@ -77,6 +77,11 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && (keyDropOpen = false)}>
<div class="prototype-notice">
Prototype — secrets are stored locally in plain SQLite, not in the system keyring.
Do not store production credentials here.
</div>
<h3 class="sh">Keyring Status</h3>
<div class="keyring-status" class:ok={keyringAvailable} class:unavail={!keyringAvailable}>
<span class="ks-dot"></span>
@ -148,6 +153,15 @@
</div>
<style>
.prototype-notice {
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--ctp-peach) 30%, transparent);
border-radius: 0.25rem;
color: var(--ctp-peach);
font-size: 0.75rem;
line-height: 1.4;
}
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.375rem 0 0.125rem; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { CSS_VAR_MAP, getPalette, applyCssVars, type ThemePalette, type ThemeId } from '../themes.ts';
@ -98,7 +98,7 @@
const parsed = JSON.parse(text);
if (parsed.name) themeName = parsed.name;
if (parsed.palette && typeof parsed.palette === 'object') {
palette = { ...basePalette };
palette = { ...initialPalette };
for (const [k, v] of Object.entries(parsed.palette)) {
if (k in palette && typeof v === 'string') {
(palette as unknown as Record<string, string>)[k] = v;