From c32915b2328aae52ea3866a4ada369d6f78a5988 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 18:26:25 +0300 Subject: [PATCH] Thin working settings UI (owner-approved plan): /settings + live appearance + keyboard rebinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation surface for the settings foundation — deliberately thin, real read/write against the delta record, polish is a later wave. Mount (owner decisions): dedicated page, single scroll; entry = sidebar bottom row + ⌘/Ctrl+, (open-settings in the registry); chat defaults NOT shown (they live on /chat/new); mode dark-only (light palette = design wave); final tool list = research/demo/visual/pdf (master-adjudicated). Sections (PUT /api/settings/[section], replace-whole per section): - General: auto-update toggle. - Agent: six background-role model+thinking selects (Default = config pin; a stored id stays VISIBLE even when the catalog hasn't loaded or dropped it — a select showing Default over a live override would lie), the four tool toggles, prompt override switches (file-gated) with source/override text editors over the segment routes. - Appearance: applied LIVE as runtime CSS variables by an AppearanceProvider at the root layout — proseFontSize → the one reading-size var, fontFamily → --font-prose, background → --background + the derived raised/accent/edge palette (dark curve, today's exact spacing: +8/+22/+47 per channel, clamped), sessionNameSerif → --font-title (titles flip with ZERO chat.tsx edits), headingWeight → new --lh-heading-weight token, bold/italic colors → the lh-ink vars. Mode renders honestly DISABLED. - Graph: the ladder grays a rung when any ANCESTOR is effectively off (stored values persist, resolve inert — switch shows stored, graying shows effective). - Keyboard: view/rebind/reset over the registry; overrides persist in lh:keymap and apply INSTANTLY (dispatcher reads per keydown); bare modifier presses are ignored while armed; displayOnly actions list but never rebind. First-boot fix: {settings: null} is no-record, not fetch-failure. CROSS-LANE (flagged for master): src/core/keyboard.ts gained the open-settings action/binding and exports saveOverrides/loadOverrides (one-liners; no existing pins); app/layout.tsx mounts the provider. Live-verified: structure, record writes (general/tools/graph/appearance), prose-size applying live (16→20px), rebind + chord navigation from another page, ladder graying, no console errors. Gates: typecheck, lint, 689/689 (28 new), build. --- app/globals.css | 6 +- app/layout.tsx | 13 +- app/settings/page.tsx | 8 + components/keyboard-shortcuts.tsx | 3 + components/settings/agent-section.tsx | 309 +++++++++++++++++++++ components/settings/appearance-section.tsx | 178 ++++++++++++ components/settings/appearance.tsx | 159 +++++++++++ components/settings/controls.tsx | 169 +++++++++++ components/settings/keyboard-section.tsx | 170 ++++++++++++ components/settings/settings-page.tsx | 135 +++++++++ components/sidebar.tsx | 16 +- src/core/keyboard.ts | 19 +- tests/render-settings.test.tsx | 150 ++++++++++ tests/settings-pure.test.ts | 139 +++++++++ 14 files changed, 1465 insertions(+), 9 deletions(-) create mode 100644 app/settings/page.tsx create mode 100644 components/settings/agent-section.tsx create mode 100644 components/settings/appearance-section.tsx create mode 100644 components/settings/appearance.tsx create mode 100644 components/settings/controls.tsx create mode 100644 components/settings/keyboard-section.tsx create mode 100644 components/settings/settings-page.tsx create mode 100644 tests/render-settings.test.tsx create mode 100644 tests/settings-pure.test.ts diff --git a/app/globals.css b/app/globals.css index 266e00b..ac65329 100644 --- a/app/globals.css +++ b/app/globals.css @@ -48,6 +48,8 @@ the reskin's hand-tuned 16.5 to the ladder's 16.) */ --lh-prose-size: 16px; --lh-prose-leading: 1.5; + /* Markdown heading weight (appearance settings; 900 = the Black cut). */ + --lh-heading-weight: 900; } /* The app is always dark (); mirror the same values so @@ -80,6 +82,8 @@ the reskin's hand-tuned 16.5 to the ladder's 16.) */ --lh-prose-size: 16px; --lh-prose-leading: 1.5; + /* Markdown heading weight (appearance settings; 900 = the Black cut). */ + --lh-heading-weight: 900; } /* CMU Serif bold (titles) — bundled local file, no CDN dependency. */ @@ -185,7 +189,7 @@ body { :where keeps specificity zero. Chrome titles (CMU Serif) don't live under .lh-prose and stay untouched. */ .lh-prose :where(h1, h2, h3, h4, h5, h6) { - font-weight: 900; + font-weight: var(--lh-heading-weight, 900); } /* lh-input (owner, typography round 2): lh-prose's 1.5 READING leading diff --git a/app/layout.tsx b/app/layout.tsx index 5719f69..d488756 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; import { ActivityProvider } from "@/components/activity-dock"; +import { AppearanceProvider } from "@/components/settings/appearance"; // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Serif (the bundled lh-cmu @font-face in globals.css — no CDN). @@ -19,11 +20,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - - - {children} - - + + + + {children} + + + ); diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..35ba7f4 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,8 @@ +/** The settings route (thin settings wave): server shell over the client + * page — same shape as the chat routes (AppShell wraps via the root + * layout, so the sidebar + its Settings entry come along). */ +import { SettingsPage } from "@/components/settings/settings-page.tsx"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index 3dc33bc..5bbce11 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -94,6 +94,9 @@ export function KeyboardShortcuts() { case "new-session": handlersRef.current.router.push("/chat/new"); break; + case "open-settings": + handlersRef.current.router.push("/settings"); + break; case "jump-graph": handlersRef.current.router.push("/"); break; diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx new file mode 100644 index 0000000..9ed301f --- /dev/null +++ b/components/settings/agent-section.tsx @@ -0,0 +1,309 @@ +"use client"; +/** + * The Agent section (thin settings wave): background-role model overrides, + * the tool toggles, and the prompt override switches — three blocks, ONE + * section (PUT /api/settings/agent replaces the whole agent object, so + * every change composes models+tools+prompts together from the record). + * + * Tools — the owner's final list (2026-09-19): research, demo, visual, pdf. + * Absent key = enabled in the schema; the UI writes explicit booleans for + * all four (simpler to reason about, sanitize-equivalent). + * + * Models: each of the six background roles gets a Default option (absent + * override = config.ts's pin) plus the /api/models catalog. The thinking + * select shows the CHOSEN model's supported levels when a model override + * exists; without one the factory model is unknown client-side, so the + * standard levels list (the server sanitizer accepts any valid level). + * + * Prompts: GET /api/settings/prompts segments; the switch requires an + * override FILE (route-enforced), the text writes through the segment + * route. Switch state lives in this section's PUT; text does not. + */ + +import { useEffect, useState } from "react"; +import { + BACKGROUND_MODEL_ROLES, + type AgentSettings, + type BackgroundModelRole, +} from "@/src/core"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ModelCatalogEntry } from "@/src/core"; +import { SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; + +/** The role's display label — schema ids are camelCase, the label is human. */ +const ROLE_LABEL: Record = { + researcher: "Researcher", + demoBuilder: "Demo builder", + visualMaker: "Visual maker", + extractor: "Extractor", + auditor: "Auditor", + labeler: "Labeler", +}; + +/** The owner's final toggleable-tool list (confirm with the user before + * changing — the Teacher stream settled this 2026-09-19). */ +export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const; + +const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + +/** SegmentInfo as GET /api/settings/prompts returns it. */ +interface SegmentInfo { + path: string; + sourceText: string; + overrideText: string | null; + enabled: boolean; + shared: boolean; +} + +/** Compose: one tool toggled, the rest of the section carried verbatim. + * Pure — exported for tests. */ +export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { + return { ...agent, tools: { ...agent?.tools, [id]: on } }; +} + +/** Compose: one role's model/thinking override. Empty model = absent + * override (Default); empty thinking = thinking falls back. Pure — tests. */ +export function composeRoleModel( + agent: AgentSettings | undefined, + role: BackgroundModelRole, + modelId: string, + thinking: string, +): AgentSettings { + const prev = agent?.models?.[role]; + const models = { ...agent?.models }; + if (!modelId && !thinking) delete models[role]; + else { + models[role] = { + ...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}), + ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}), + }; + } + return { ...agent, models }; +} + +/** Compose: one prompt switch. on = explicit true; off = key REMOVED + * (absent = off is the schema's resting state; explicit false is the + * same thing but noisier). Pure — tests. */ +export function composePromptSwitch(agent: AgentSettings | undefined, path: string, on: boolean): AgentSettings { + const prompts = { ...agent?.prompts }; + if (on) prompts[path] = true; + else delete prompts[path]; + return { ...agent, prompts }; +} + +export function AgentSection({ + agent, + onSectionChange, +}: { + agent: AgentSettings | undefined; + /** Writes the composed WHOLE agent section (replace-PUT discipline). */ + onSectionChange: (next: AgentSettings) => void; +}) { + const [catalog, setCatalog] = useState(null); + const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null); + const [openSegment, setOpenSegment] = useState(null); + const [segmentDraft, setSegmentDraft] = useState>({}); + const [segmentStatus, setSegmentStatus] = useState(null); + + // The catalog + the prompt segments — one fetch each per page load. + useEffect(() => { + fetch("/api/models") + .then((r) => r.json()) + .then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models)) + .catch(() => setCatalog(null)); + fetch("/api/settings/prompts") + .then((r) => r.json()) + .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) + .catch(() => setPrompts(null)); + }, []); + + async function saveSegment(path: string, text: string) { + setSegmentStatus(null); + const res = await fetch(`/api/settings/prompts/${path}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!res.ok) { + setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`); + return; + } + // Refetch: file presence gates the switch, so the view must track it. + fetch("/api/settings/prompts") + .then((r) => r.json()) + .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); + setSegmentStatus(`Saved ${path}`); + } + + async function deleteSegment(path: string) { + setSegmentStatus(null); + const res = await fetch(`/api/settings/prompts/${path}`, { method: "DELETE" }); + if (!res.ok) { + setSegmentStatus(`Delete failed: ${res.status}`); + return; + } + fetch("/api/settings/prompts") + .then((r) => r.json()) + .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); + } + + const segmentRow = (info: SegmentInfo) => { + const fileExists = info.overrideText !== null; + const open = openSegment === info.path; + return ( +
+ + onSectionChange(composePromptSwitch(agent, info.path, on))} + /> + + + {open && ( +
+ {info.shared && ( +

Shared base — appears in both agents.

+ )} +
+

Source

+