Publish web editor and Vercel deploy for open-gsd org.

Adds cloud editor (upload/download), full preferences coverage, pi-coding-agent
settings UI, gallery integration, and Vercel config for pi.opengsd.net at root path.
Points release metadata at open-gsd/gsd-pi-config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jeremy McSpadden
2026-05-25 11:15:29 -05:00
parent d264d07966
commit e34668656e
105 changed files with 10875 additions and 1729 deletions
+45
View File
@@ -0,0 +1,45 @@
# GSD Pi Config — DESIGN
Aligned with [opengsd.net](https://www.opengsd.net) (product register).
## Scene
Engineers configure GSD Pi at a desk, often beside a terminal, in a dim room. The UI is a focused tool: dark, low glare, cyan accent for primary actions only.
## Color strategy
**Restrained** with **Committed** accent: tinted neutrals + cyan `#22d3ee` for links, primary buttons, active nav, focus rings.
| Token | Dark | Role |
|-------|------|------|
| background | `#050507` | Page |
| background-elevated | `#0d0d14` | Sidebar, panels, inputs |
| foreground | `#e7e8f0` | Body text |
| muted | `#8b8ca6` | Labels, hints |
| border | `#1b1b2a` | Dividers |
| accent | `#22d3ee` | Primary actions |
| accent-strong | `#38e0ff` | Hover on accent |
| purple | `#a855f7` | Grid accent only (5% opacity) |
Never pure `#000` / `#fff`. Light theme uses the same hue family with higher lightness.
## Typography
- **Sans:** Geist Sans (marketing parity)
- **Mono:** Geist Mono for paths, code, presets
- Scale ratio ~1.2; body 14px; section titles 13px semibold uppercase tracking
## Layout
- Top **WebShell** on cloud: brand + Editor / Gallery / New + link to opengsd.net
- Editor: sidebar (sections) + toolbar (workspace label + file actions) + content
- No project scope on web. Flow: upload → edit → download.
## Motion
150200ms ease-out-quart on color/border. No page-load choreography.
## Anti-patterns
- Side-stripe borders, gradient text, glass cards, hero metric templates
- Native `<select multiple>` listboxes (use checkbox dropdown)
+8
View File
@@ -0,0 +1,8 @@
# Web build (copy to .env.web.local for `npm run dev:web`)
VITE_BASE_PATH=/gsd-pi-config/
VITE_PRESETS_INDEX_URL=https://raw.githubusercontent.com/open-gsd/gsd-pi-presets/main/index.json
VITE_PRESETS_RAW_BASE_URL=https://raw.githubusercontent.com/open-gsd/gsd-pi-presets/main/
VITE_PRESETS_CONTRIBUTING_URL=https://github.com/open-gsd/gsd-pi-presets/blob/main/CONTRIBUTING.md
# Optional: GitHub OAuth for in-app preset submit (requires api/submit-preset deployed)
VITE_GITHUB_CLIENT_ID=
VITE_SUBMIT_PRESET_API_URL=/api/submit-preset
+6
View File
@@ -0,0 +1,6 @@
# Production web build (Vercel: pi.opengsd.net — root path, not GitHub Pages subpath)
VITE_BASE_PATH=/
VITE_PRESETS_INDEX_URL=https://raw.githubusercontent.com/open-gsd/gsd-pi-presets/main/index.json
VITE_PRESETS_RAW_BASE_URL=https://raw.githubusercontent.com/open-gsd/gsd-pi-presets/main/
VITE_PRESETS_CONTRIBUTING_URL=https://github.com/open-gsd/gsd-pi-presets/blob/main/CONTRIBUTING.md
VITE_SUBMIT_PRESET_API_URL=/api/submit-preset
+67
View File
@@ -0,0 +1,67 @@
# GSD Pi Config — deploy web app to GitHub Pages
# Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
name: Deploy Web (Pages)
on:
push:
branches: [main]
paths:
- "src/**"
- "index.html"
- "vite.config.ts"
- "package.json"
- "package-lock.json"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
test:
name: Test (preferences core)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm test
build:
name: Build web
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run build:web
env:
VITE_BASE_PATH: /gsd-pi-config/
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+4 -4
View File
@@ -1,4 +1,4 @@
# GSD2 Config — cross-platform release workflow
# GSD Pi Config — cross-platform release workflow
# Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
#
# Security note: this workflow only interpolates `secrets.GITHUB_TOKEN`,
@@ -70,11 +70,11 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: `GSD2 Config ${tag}`,
name: `GSD Pi Config ${tag}`,
body: [
'## GSD2 Config ' + tag,
'## GSD Pi Config ' + tag,
'',
'Desktop configuration manager for GSD-2 preferences — Tauri + React GUI for `~/.gsd/preferences.md`, with skills, agents, and keychain-backed API keys.',
'Desktop configuration manager for GSD Pi preferences — Tauri + React GUI for `~/.gsd/preferences.md`, with skills, agents, and keychain-backed API keys.',
'',
'### Install',
'',
+31
View File
@@ -0,0 +1,31 @@
# GSD Pi Config — frontend unit tests on PR
# Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
name: Test Frontend
on:
pull_request:
paths:
- "src/**"
- "package.json"
- "package-lock.json"
- "vite.config.ts"
push:
branches: [main]
paths:
- "src/**"
- "package.json"
- "package-lock.json"
- "vite.config.ts"
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm test
+1
View File
@@ -26,3 +26,4 @@ Thumbs.db
.vscode/
.idea/
*.swp
.vercel
+65 -5
View File
@@ -1,11 +1,13 @@
<!--
GSD2 Config — README
GSD Pi Config — README
Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
-->
# GSD2 Config
# GSD Pi Config
Desktop configuration manager for [GSD-2](https://github.com/jmcspadden/gsd) preferences. A native Tauri app that gives you a structured GUI over the YAML preferences file you'd otherwise hand-edit, plus a library view for skills, agents, and API keys.
Desktop configuration manager for [GSD Pi](https://github.com/open-gsd/gsd-pi) preferences. A native Tauri app that gives you a structured GUI over the YAML preferences file you'd otherwise hand-edit, plus a library view for skills, agents, and API keys.
**Web app:** Cloud-hosted editor — upload or create a configuration, edit in the browser, then download `preferences.md`, `models.json`, and `settings.json` for your machine. Preset gallery and wizard included (see [Web vs desktop](#web-vs-desktop)).
## Screenshots
@@ -39,7 +41,7 @@ Desktop configuration manager for [GSD-2](https://github.com/jmcspadden/gsd) pre
- Rust toolchain (stable)
- Tauri 2 prerequisites for your platform — see [tauri.app/start/prerequisites](https://tauri.app/start/prerequisites/)
### Install & run
### Install & run (desktop)
```bash
npm install
@@ -48,6 +50,64 @@ npm run tauri dev
The dev server runs on port `1420`. The app window opens automatically once the Rust backend compiles.
### Web (local)
```bash
npm install
npm run dev:web
```
Opens the gallery + editor on port `5173`. Copy `.env.web.example` to `.env.web.local` to override preset repo URLs.
```bash
npm run build:web # static dist/ for GitHub Pages
npm run preview:web
npm test # preferencesCore unit tests
```
Preset gallery data lives in [`gsd-pi-presets/`](gsd-pi-presets/) (publish as [open-gsd/gsd-pi-presets](https://github.com/open-gsd/gsd-pi-presets)).
### Deploy to Vercel (`pi.opengsd.net`)
Production uses root path `/` (see `.env.web.production`). GitHub Pages builds use `/gsd-pi-config/` via the Pages workflow.
1. Import the repo in [Vercel](https://vercel.com) (team **fluxlabs-projects** or your org).
2. Framework preset: **Other**`vercel.json` sets `buildCommand`, `outputDirectory`, and SPA rewrites.
3. **Environment variables** (Project → Settings → Environment Variables):
| Variable | Required | Notes |
|----------|----------|--------|
| `GITHUB_CLIENT_ID` | For preset submit | GitHub OAuth App |
| `GITHUB_CLIENT_SECRET` | For preset submit | Server only |
| `PRESETS_REPO` | Optional | Default `open-gsd/gsd-pi-presets` |
Build-time `VITE_*` vars come from `.env.web.production` in the repo.
4. **Domain:** Project → Settings → Domains → add `pi.opengsd.net`. At your DNS host, add a CNAME (or Vercels recommended A/ALIAS records) pointing to Vercel.
5. **GitHub OAuth App** (if using Submit preset): set callback URL to `https://pi.opengsd.net/oauth/callback`.
```bash
npm i -g vercel # optional CLI
vercel link # link to project
vercel --prod # production deploy
```
### Web vs desktop
The web app is a **cloud editor**: it cannot read or write files on your computer. Import existing configs (or start from a preset/wizard), edit in the session, then **Download files** to install under `~/.gsd/` locally. A browser draft is kept so you can refresh without losing work in that session.
| Feature | Web | Desktop |
|---------|-----|---------|
| Preference sections (editor) | Yes | Yes |
| Edit `~/.gsd/` or project files in place | No — upload + download | Yes |
| Global / project scope | Global only | Global + real project folders |
| `models.json` / `settings.json` | Import + download | On disk |
| Skills / agents libraries | No | Yes (`~/.claude`, project) |
| API keys | Browser session; export `env.sh` | OS keychain |
| CLI auth detection (`gcloud`, `gh`) | No | Yes |
| Preset gallery / wizard | Yes | Import/export files |
| Auto-update installer | No | Yes |
### Build a release bundle
```bash
@@ -73,7 +133,7 @@ src/ React frontend
components/sections/ One file per preferences section
hooks/ useDirty (change tracking)
lib/ keyboard, presets, theme, validators, tauri listeners
types.ts TypeScript mirror of the GSD-2 preferences schema
types.ts TypeScript mirror of the GSD Pi preferences schema
src-tauri/ Rust backend
src/lib.rs Tauri command handlers (preferences, skills, agents, keys)
src/core.rs YAML serialization, frontmatter parsing, atomic writes
+171
View File
@@ -0,0 +1,171 @@
// GSD Pi Config - GitHub preset submit API (Vercel serverless)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Env: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, PRESETS_REPO (default open-gsd/gsd-pi-presets)
import type { VercelRequest, VercelResponse } from "@vercel/node";
const PRESETS_REPO = process.env.PRESETS_REPO ?? "open-gsd/gsd-pi-presets";
const GITHUB_API = "https://api.github.com";
interface SubmitBody {
code?: string;
redirectUri?: string;
accessToken?: string;
slug?: string;
title?: string;
description?: string;
presetMarkdown?: string;
}
async function exchangeCode(code: string, redirectUri: string): Promise<string> {
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GitHub OAuth is not configured on the server");
}
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
}),
});
const data = (await res.json()) as { access_token?: string; error?: string };
if (!res.ok || !data.access_token) {
throw new Error(data.error ?? "OAuth token exchange failed");
}
return data.access_token;
}
async function gh(
token: string,
path: string,
init?: RequestInit,
): Promise<Response> {
return fetch(`${GITHUB_API}${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
...(init?.headers ?? {}),
},
});
}
async function createPresetPr(
token: string,
slug: string,
title: string,
description: string,
presetMarkdown: string,
): Promise<string> {
const [owner, repo] = PRESETS_REPO.split("/");
if (!owner || !repo) throw new Error("Invalid PRESETS_REPO");
const userRes = await gh(token, "/user");
const user = (await userRes.json()) as { login?: string };
if (!user.login) throw new Error("Could not read GitHub user");
const forkRes = await gh(token, `/repos/${owner}/${repo}/forks`, { method: "POST" });
if (!forkRes.ok && forkRes.status !== 422) {
const err = await forkRes.text();
throw new Error(`Fork failed: ${err}`);
}
const forkOwner = user.login;
const branch = `preset/${slug}-${Date.now()}`;
const filePath = `presets/${slug}.preset.md`;
const refRes = await gh(token, `/repos/${forkOwner}/${repo}/git/ref/heads/main`);
if (!refRes.ok) throw new Error("Could not read main branch on fork");
const refData = (await refRes.json()) as { object: { sha: string } };
const baseSha = refData.object.sha;
await gh(token, `/repos/${forkOwner}/${repo}/git/refs`, {
method: "POST",
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }),
});
const frontmatter = `title: ${JSON.stringify(title)}
description: ${JSON.stringify(description)}
tags: []
author: ${JSON.stringify(forkOwner)}
`;
const fullContent =
presetMarkdown.trimStart().startsWith("---")
? presetMarkdown
: `---\n${frontmatter}---\n${presetMarkdown.replace(/^---[\s\S]*?---\n?/, "")}`;
const contentB64 = Buffer.from(fullContent, "utf8").toString("base64");
const putRes = await gh(token, `/repos/${forkOwner}/${repo}/contents/${filePath}`, {
method: "PUT",
body: JSON.stringify({
message: `Add preset: ${title}`,
content: contentB64,
branch,
}),
});
if (!putRes.ok) {
const err = await putRes.text();
throw new Error(`Commit failed: ${err}`);
}
const prRes = await gh(token, `/repos/${owner}/${repo}/pulls`, {
method: "POST",
body: JSON.stringify({
title: `Add preset: ${title}`,
head: `${forkOwner}:${branch}`,
base: "main",
body: `${description}\n\nSubmitted via GSD Pi Config web.`,
}),
});
if (!prRes.ok) {
const err = await prRes.text();
throw new Error(`PR failed: ${err}`);
}
const pr = (await prRes.json()) as { html_url: string };
return pr.html_url;
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== "POST") {
res.status(405).json({ error: "Method not allowed" });
return;
}
try {
const body = req.body as SubmitBody;
let token = body.accessToken;
if (body.code && body.redirectUri) {
token = await exchangeCode(body.code, body.redirectUri);
}
if (!token) {
res.status(400).json({ error: "Missing access token or OAuth code" });
return;
}
if (!body.slug || !body.presetMarkdown) {
res.status(400).json({ error: "Missing slug or presetMarkdown" });
return;
}
const prUrl = await createPresetPr(
token,
body.slug,
body.title ?? body.slug,
body.description ?? "",
body.presetMarkdown,
);
res.status(200).json({ prUrl });
} catch (e) {
res.status(500).json({ error: String(e) });
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GSD2 Config</title>
<title>GSD Pi Config</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/style.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css" />
</head>
<body>
<div id="root"></div>
+2382 -4
View File
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -1,13 +1,17 @@
{
"name": "gsd-setup",
"name": "gsd-pi-config",
"version": "1.1.0",
"description": "Desktop configuration manager for GSD-2 preferences",
"description": "Desktop configuration manager for GSD Pi preferences",
"author": "Jeremy McSpadden <jeremy@fluxlabs.net>",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite --port 1420",
"dev:web": "vite --mode web",
"build": "tsc && vite build",
"build:web": "tsc && vite build --mode web",
"preview:web": "vite preview --mode web",
"test": "vitest run",
"tauri": "tauri"
},
"dependencies": {
@@ -18,16 +22,21 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
"react-dom": "^19.2.5",
"react-router-dom": "^7.13.1",
"yaml": "^2.8.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2.10.1",
"@types/node": "^25.9.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vercel/node": "^5.5.27",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.8"
"vite": "^8.0.8",
"vitest": "^4.0.18"
}
}
+1 -1
View File
@@ -1552,7 +1552,7 @@ dependencies = [
]
[[package]]
name = "gsd2-config"
name = "gsd-pi-config"
version = "1.1.0"
dependencies = [
"dirs",
+3 -3
View File
@@ -1,10 +1,10 @@
[package]
name = "gsd2-config"
name = "gsd-pi-config"
version = "1.1.0"
description = "Desktop configuration manager for GSD-2 preferences"
description = "Desktop configuration manager for GSD Pi preferences"
authors = ["Jeremy McSpadden <jeremy@fluxlabs.net>"]
license = "MIT"
repository = "https://github.com/jeremymcs/gsd2-config"
repository = "https://github.com/open-gsd/gsd-pi-config"
edition = "2021"
rust-version = "1.77.2"
+12 -12
View File
@@ -1,8 +1,8 @@
// GSD2 Config - Core preferences/filesystem primitives
// GSD Pi Config - Core preferences/filesystem primitives
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Pure (non-Tauri) logic that both the GUI command layer and the future
// `gsd-setup-cli` binary depend on. Kept free of tauri types on purpose.
// `gsd-pi-config-cli` binary depend on. Kept free of tauri types on purpose.
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -218,9 +218,9 @@ pub fn save_preferences_at(path: &Path, prefs: &Value) -> Result<(), String> {
// settings.json and models.json are plain JSON (not YAML frontmatter), but
// they share the same safety needs as preferences.md: atomic write, per-path
// mutex, `.bak` sibling, and protection against silently clobbering edits
// made by GSD2 itself while the editor was open.
// made by GSD Pi itself while the editor was open.
//
// Cross-process safety: `with_file_lock` only guards in-process races. GSD2
// Cross-process safety: `with_file_lock` only guards in-process races. GSD Pi
// can write to these files too, so we compare the on-disk mtime to an
// `expected_mtime_ms` captured at load time and refuse the save on mismatch
// with a `STALE:` prefix the UI can detect.
@@ -234,7 +234,7 @@ pub enum ConfigDoc {
/// Typed resolver for every config file the editor touches.
///
/// Path asymmetry is intentional and matches what GSD2 actually reads:
/// Path asymmetry is intentional and matches what GSD Pi actually reads:
/// - project settings.json lives at `<p>/.gsd/settings.json`
/// - project models.json lives at `<p>/.gsd/agent/models.json`
/// Centralising the table here keeps the asymmetry explicit and testable.
@@ -376,12 +376,12 @@ mod tests {
],
"custom_instructions": ["line 1", "line 2"],
"models": {
"research": "claude-sonnet-4-5",
"planning": "claude-sonnet-4-5",
"research": "gpt-4o-mini",
"planning": "gpt-4o",
"execution": {
"model": "claude-opus-4-6",
"provider": "anthropic",
"fallbacks": ["claude-sonnet-4-5"]
"model": "gpt-4o",
"provider": "openai",
"fallbacks": ["gpt-4o-mini"]
}
},
"budget_ceiling": 10.5,
@@ -412,7 +412,7 @@ mod tests {
"context_management": { "observation_masking": true, "observation_mask_turns": 3, "compaction_threshold_percent": 75, "tool_result_max_chars": 20000 },
"dynamic_routing": {
"enabled": true,
"tier_models": { "light": "haiku", "standard": "sonnet", "heavy": "opus" },
"tier_models": { "light": "openai/gpt-4o-mini", "standard": "openai/gpt-4o", "heavy": "anthropic/claude-opus-4-6" },
"escalate_on_failure": true,
"budget_pressure": true,
"cross_provider": false,
@@ -593,7 +593,7 @@ mod tests {
let path = tmp.path().join("models.json");
save_json_at(&path, &json!({ "providers": {} }), None).unwrap();
let doc = load_json_at(&path).unwrap();
// Simulate GSD2 writing to the file externally.
// Simulate GSD Pi writing to the file externally.
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&path, b"{\"providers\":{\"ext\":{}}}").unwrap();
let err = save_json_at(&path, &json!({ "providers": { "ours": {} } }), Some(doc.mtime_ms))
+104 -12
View File
@@ -1,4 +1,4 @@
// GSD2 Config - Tauri Backend (command layer over `core`)
// GSD Pi Config - Tauri Backend (command layer over `core`)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
pub mod core;
@@ -15,7 +15,39 @@ use crate::core::{
serialize_preferences, write_atomic, ConfigDoc, JsonDoc,
};
const KEYRING_SERVICE: &str = "net.fluxlabs.gsd2-config";
const KEYRING_SERVICE: &str = "net.fluxlabs.gsd-pi-config";
const LEGACY_KEYRING_SERVICE: &str = "net.fluxlabs.gsd2-config";
/// Env var names managed in the API Keys UI (`ApiKeysSection.tsx`). Used for
/// one-time startup migration from the pre-rebrand keychain service.
const KNOWN_API_KEY_NAMES: &[&str] = &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"OPENAI_API_KEY",
"OPENAI_ORG_ID",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"XAI_API_KEY",
"DEEPSEEK_API_KEY",
"MISTRAL_API_KEY",
"GROQ_API_KEY",
"CEREBRAS_API_KEY",
"OPENROUTER_API_KEY",
"VERCEL_AI_GATEWAY_KEY",
"DASHSCOPE_API_KEY",
"ZHIPU_API_KEY",
"MOONSHOT_API_KEY",
"TAVILY_API_KEY",
"BRAVE_API_KEY",
"EXA_API_KEY",
"GOOGLE_SEARCH_API_KEY",
"GOOGLE_CSE_ID",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_REGION",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
];
fn ensure_parent_dir(path: &PathBuf) -> Result<(), String> {
if let Some(parent) = path.parent() {
@@ -50,7 +82,7 @@ fn get_preferences_path(project_path: Option<String>) -> Result<String, String>
// ─── Settings / Models commands ─────────────────────────────────────────────
//
// GSD2 reads three independent config files: preferences.md (above),
// GSD Pi reads three independent config files: preferences.md (above),
// settings.json (agent runtime), and models.json (custom providers). Settings
// and models are plain JSON and share the same load/save machinery in
// `core::{load_json_at, save_json_at}`. Each returns a JsonDoc (value + mtime)
@@ -142,6 +174,19 @@ fn import_preset(source_path: String) -> Result<Value, String> {
load_preferences_at(&path)
}
/// Load arbitrary JSON config (models.json, settings.json) from a user-picked path.
#[tauri::command]
fn import_json_file(source_path: String) -> Result<Value, String> {
if source_path.trim().is_empty() {
return Err("Source path is empty".to_string());
}
let path = PathBuf::from(&source_path);
if !path.exists() {
return Err(format!("File does not exist: {}", source_path));
}
Ok(load_json_at(&path)?.value)
}
/// Walk a JSON value and replace any string value whose key contains
/// `key`/`token`/`secret`/`password` (case-insensitive) with `<redacted>`.
/// Recurses through objects and arrays. Non-string values under sensitive
@@ -234,7 +279,7 @@ fn skill_roots(project_path: Option<&str>) -> Vec<(PathBuf, &'static str)> {
}
/// Legacy GSD-1 skills live under `.claude/skills/gsd-*` and should not be
/// surfaced in the GSD-2 config manager. Returns true when the given skill
/// surfaced in the GSD Pi config manager. Returns true when the given skill
/// directory sitting under a `.claude/skills` root should be filtered out.
fn is_legacy_gsd_skill(root: &Path, dir_name: &str) -> bool {
if !dir_name.starts_with("gsd-") {
@@ -465,13 +510,54 @@ struct KeyStatus {
preview: Option<String>,
}
fn keyring_entry(name: &str) -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, name).map_err(|e| format!("Keyring error: {}", e))
fn keyring_entry(service: &str, name: &str) -> Result<Entry, String> {
Entry::new(service, name).map_err(|e| format!("Keyring error: {}", e))
}
fn current_keyring_entry(name: &str) -> Result<Entry, String> {
keyring_entry(KEYRING_SERVICE, name)
}
/// Copy a credential from `net.fluxlabs.gsd2-config` when the new service has
/// no entry yet. Removes the legacy entry after a successful copy.
fn migrate_legacy_keyring_entry(name: &str) -> Result<(), String> {
let new_entry = current_keyring_entry(name)?;
match new_entry.get_password() {
Ok(_) => return Ok(()),
Err(keyring::Error::NoEntry) => {}
Err(e) => return Err(format!("Failed to read key {}: {}", name, e)),
}
let legacy_entry = keyring_entry(LEGACY_KEYRING_SERVICE, name)?;
match legacy_entry.get_password() {
Ok(value) => {
new_entry
.set_password(&value)
.map_err(|e| format!("Failed to migrate key {}: {}", name, e))?;
if let Err(e) = legacy_entry.delete_credential() {
log::warn!("Migrated {} but could not remove legacy keyring entry: {}", name, e);
} else {
log::info!("Migrated keyring entry {} from legacy service", name);
}
Ok(())
}
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to read legacy key {}: {}", name, e)),
}
}
fn migrate_all_legacy_keyring_entries() {
for name in KNOWN_API_KEY_NAMES {
if let Err(e) = migrate_legacy_keyring_entry(name) {
log::warn!("Keyring migration skipped for {}: {}", name, e);
}
}
}
#[tauri::command]
fn get_key(name: String) -> Result<Option<String>, String> {
let entry = keyring_entry(&name)?;
migrate_legacy_keyring_entry(&name)?;
let entry = current_keyring_entry(&name)?;
match entry.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
@@ -484,7 +570,8 @@ fn set_key(name: String, value: String) -> Result<(), String> {
if value.is_empty() {
return delete_key(name);
}
let entry = keyring_entry(&name)?;
migrate_legacy_keyring_entry(&name)?;
let entry = current_keyring_entry(&name)?;
entry
.set_password(&value)
.map_err(|e| format!("Failed to set key: {}", e))
@@ -492,7 +579,8 @@ fn set_key(name: String, value: String) -> Result<(), String> {
#[tauri::command]
fn delete_key(name: String) -> Result<(), String> {
let entry = keyring_entry(&name)?;
migrate_legacy_keyring_entry(&name)?;
let entry = current_keyring_entry(&name)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
@@ -504,7 +592,8 @@ fn delete_key(name: String) -> Result<(), String> {
fn list_key_statuses(names: Vec<String>) -> Result<Vec<KeyStatus>, String> {
let mut result = Vec::with_capacity(names.len());
for name in names {
let entry = keyring_entry(&name)?;
migrate_legacy_keyring_entry(&name)?;
let entry = current_keyring_entry(&name)?;
match entry.get_password() {
Ok(v) => {
let preview = if v.len() > 4 {
@@ -539,13 +628,14 @@ fn export_env_file(names: Vec<String>) -> Result<String, String> {
let mut lines: Vec<String> = Vec::new();
lines.push("#!/usr/bin/env bash".to_string());
lines.push("# Generated by GSD2 Config — do not edit by hand.".to_string());
lines.push("# Generated by GSD Pi Config — do not edit by hand.".to_string());
lines.push("# Source this file from your shell profile:".to_string());
lines.push("# [ -f ~/.gsd/env.sh ] && source ~/.gsd/env.sh".to_string());
lines.push(String::new());
for name in names {
let entry = keyring_entry(&name)?;
migrate_legacy_keyring_entry(&name)?;
let entry = current_keyring_entry(&name)?;
match entry.get_password() {
Ok(v) => {
// Single-quote and escape internal single quotes
@@ -884,6 +974,7 @@ pub fn run() {
.build(),
)?;
}
migrate_all_legacy_keyring_entries();
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -898,6 +989,7 @@ pub fn run() {
get_models_path,
export_preset,
import_preset,
import_json_file,
build_shareable_preset,
list_skills,
read_skill,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "GSD2 Config",
"productName": "GSD Pi Config",
"version": "1.1.0",
"identifier": "net.fluxlabs.gsd2-config",
"identifier": "net.fluxlabs.gsd-pi-config",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "GSD2 Config",
"title": "GSD Pi Config",
"width": 1100,
"height": 750,
"minWidth": 800,
@@ -44,7 +44,7 @@
"plugins": {
"updater": {
"endpoints": [
"https://github.com/jeremymcs/gsd2-config/releases/latest/download/latest.json"
"https://github.com/open-gsd/gsd-pi-config/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCRTYxNjg4MzYzMjM1RTEKUldUaE5USTJpQmJtaXpjd1hTcTg0bnhCaHM0QzNPdzBzd0J5d2JCS1JsLytWbFlvS052VkxJS2oK"
}
+4
View File
@@ -0,0 +1,4 @@
// GSD Pi Config - Desktop (Tauri) entry
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
export { DesktopApp as default } from "./DesktopApp";
+2 -819
View File
@@ -1,821 +1,4 @@
// GSD2 Config - Main Application Component
// GSD Pi Config - Platform entry re-export (desktop default for Tauri)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open, save as saveDialog } from "@tauri-apps/plugin-dialog";
import { Sidebar, SECTIONS, type SectionId } from "./components/Sidebar";
import { Palette } from "./components/Palette";
import { ShareModal } from "./components/ShareModal";
import { ThemeToggle } from "./components/ThemeToggle";
import { useDirty } from "./hooks/useDirty";
import { useShortcuts } from "./lib/keyboard";
import { useCloseRequested } from "./lib/tauriListeners";
import {
checkForUpdate,
downloadAndInstallUpdate,
type UpdateCheck,
} from "./lib/updater";
import type { GSDPreferences, GSDModelsConfig } from "./types";
import { GeneralSection } from "./components/sections/GeneralSection";
import { ModelsSection } from "./components/sections/ModelsSection";
import { GitSection } from "./components/sections/GitSection";
import { SkillsSection } from "./components/sections/SkillsSection";
import { BudgetSection } from "./components/sections/BudgetSection";
import { NotificationsSection } from "./components/sections/NotificationsSection";
import { ParallelSection } from "./components/sections/ParallelSection";
import { PhasesSection } from "./components/sections/PhasesSection";
import { ContextSection } from "./components/sections/ContextSection";
import { SafetySection } from "./components/sections/SafetySection";
import { VerificationSection } from "./components/sections/VerificationSection";
import { DiscussionSection } from "./components/sections/DiscussionSection";
import { HooksSection } from "./components/sections/HooksSection";
import { RoutingSection } from "./components/sections/RoutingSection";
import { CmuxSection } from "./components/sections/CmuxSection";
import { RemoteSection } from "./components/sections/RemoteSection";
import { CodebaseSection } from "./components/sections/CodebaseSection";
import { ExperimentalSection } from "./components/sections/ExperimentalSection";
import { SkillsLibrarySection } from "./components/sections/SkillsLibrarySection";
import { AgentsLibrarySection } from "./components/sections/AgentsLibrarySection";
import { ApiKeysSection } from "./components/sections/ApiKeysSection";
import { CustomProvidersSection } from "./components/sections/CustomProvidersSection";
import { AgentSettingsSection } from "./components/sections/AgentSettingsSection";
type SaveStatus = "idle" | "saving" | "saved" | "error";
type Scope = "global" | "project";
const RECENT_PROJECTS_KEY = "gsd2-config.recent-projects";
const LAST_SCOPE_KEY = "gsd2-config.last-scope";
const LAST_PROJECT_KEY = "gsd2-config.last-project";
/**
* Strip undefined/null values and empty objects recursively for clean YAML output.
*
* Known limitation: empty arrays are pruned (a user clearing a list to `[]`
* loses the key on save). Intentional for now — users don't typically
* distinguish "unset" from "empty" for these fields. See
* .plans/qol-and-features-v1.md before changing.
*/
function cleanPrefs(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
if (val === undefined || val === null) continue;
if (Array.isArray(val)) {
if (val.length > 0) result[key] = val;
} else if (typeof val === "object") {
const cleaned = cleanPrefs(val as Record<string, unknown>);
if (Object.keys(cleaned).length > 0) result[key] = cleaned;
} else {
result[key] = val;
}
}
return result;
}
function loadRecentProjects(): string[] {
try {
const stored = localStorage.getItem(RECENT_PROJECTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveRecentProjects(projects: string[]) {
try {
localStorage.setItem(RECENT_PROJECTS_KEY, JSON.stringify(projects));
} catch {
// ignore
}
}
export default function App() {
const [section, setSection] = useState<SectionId>("general");
const [prefs, setPrefs] = useState<GSDPreferences>({});
const [originalPrefs, setOriginalPrefs] = useState<string>("{}");
const [status, setStatus] = useState<SaveStatus>("idle");
const [savedCount, setSavedCount] = useState(0);
const [filePath, setFilePath] = useState("");
const [error, setError] = useState("");
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
const [scope, setScope] = useState<Scope>(() => {
const saved = localStorage.getItem(LAST_SCOPE_KEY);
return saved === "project" ? "project" : "global";
});
const [projectPath, setProjectPath] = useState<string>(() => {
return localStorage.getItem(LAST_PROJECT_KEY) ?? "";
});
const [recentProjects, setRecentProjects] = useState<string[]>(() => loadRecentProjects());
// Second config document: ~/.gsd/agent/models.json (or project equivalent).
// Tracked independently of preferences.md — same dirty/save flow, its own
// mtime baseline for cross-process staleness detection.
const [modelsDoc, setModelsDoc] = useState<GSDModelsConfig>({});
const [originalModels, setOriginalModels] = useState<string>("{}");
const [modelsMtime, setModelsMtime] = useState<number>(0);
// Third config document: ~/.gsd/agent/settings.json (Claude Code settings).
// Free-form Record so unknown keys (hooks, enterprise fields) round-trip.
const [settingsDoc, setSettingsDoc] = useState<Record<string, unknown>>({});
const [originalSettings, setOriginalSettings] = useState<string>("{}");
const [settingsMtime, setSettingsMtime] = useState<number>(0);
// Auto-update state. Silent check runs once on mount; banner appears only
// if an update is available and the user hasn't dismissed it this session.
const [updateInfo, setUpdateInfo] = useState<UpdateCheck | null>(null);
const [updateDismissed, setUpdateDismissed] = useState(false);
const [updateInstalling, setUpdateInstalling] = useState(false);
const [updateChecking, setUpdateChecking] = useState(false);
const runUpdateCheck = useCallback(async (manual: boolean) => {
setUpdateChecking(true);
try {
const result = await checkForUpdate();
setUpdateInfo(result);
if (manual) setUpdateDismissed(false);
} finally {
setUpdateChecking(false);
}
}, []);
useEffect(() => {
// Silent check on mount — errors swallowed inside checkForUpdate().
runUpdateCheck(false);
}, [runUpdateCheck]);
const installUpdate = async () => {
if (!updateInfo?.handle) return;
setUpdateInstalling(true);
try {
await downloadAndInstallUpdate(updateInfo.handle);
// If relaunch() returns, something's off — leave the banner spinning
// so the user knows install finished but relaunch didn't fire.
} catch (e) {
setError(`Update failed: ${String(e)}`);
setUpdateInstalling(false);
}
};
const activeProjectPath = scope === "project" ? projectPath : undefined;
const load = useCallback(async () => {
try {
setError("");
const args = activeProjectPath ? { projectPath: activeProjectPath } : {};
const data = await invoke<GSDPreferences>("load_preferences", args);
setPrefs(data);
setOriginalPrefs(JSON.stringify(data));
const path = await invoke<string>("get_preferences_path", args);
setFilePath(path);
// models.json — second document, independent failure domain. A missing
// or malformed file should not block preferences editing.
try {
const snap = await invoke<{ value: GSDModelsConfig | null; mtime_ms: number }>(
"load_models",
args,
);
const next = snap.value ?? {};
setModelsDoc(next);
setOriginalModels(JSON.stringify(next));
setModelsMtime(snap.mtime_ms ?? 0);
} catch (modelsErr) {
console.warn("load_models failed:", modelsErr);
setModelsDoc({});
setOriginalModels("{}");
setModelsMtime(0);
}
// settings.json — third document, independent failure domain.
try {
const snap = await invoke<{
value: Record<string, unknown> | null;
mtime_ms: number;
}>("load_settings", args);
const next = snap.value ?? {};
setSettingsDoc(next);
setOriginalSettings(JSON.stringify(next));
setSettingsMtime(snap.mtime_ms ?? 0);
} catch (settingsErr) {
console.warn("load_settings failed:", settingsErr);
setSettingsDoc({});
setOriginalSettings("{}");
setSettingsMtime(0);
}
} catch (e) {
setError(String(e));
setPrefs({});
setOriginalPrefs("{}");
setModelsDoc({});
setOriginalModels("{}");
setModelsMtime(0);
setSettingsDoc({});
setOriginalSettings("{}");
setSettingsMtime(0);
}
}, [activeProjectPath]);
useEffect(() => {
// Only load when scope is global, or project with a valid path
if (scope === "global" || (scope === "project" && projectPath)) {
load();
} else {
setPrefs({});
setOriginalPrefs("{}");
setFilePath("");
}
}, [scope, projectPath, load]);
const { isDirty, dirtySections, dirtyPaths } = useDirty(prefs, originalPrefs);
// models.json dirty check — simple JSON-string compare since the doc is a
// free-form registry shape and field-level paths don't make sense here.
const isModelsDirty = useMemo(
() => JSON.stringify(modelsDoc) !== originalModels,
[modelsDoc, originalModels],
);
const isSettingsDirty = useMemo(
() => JSON.stringify(settingsDoc) !== originalSettings,
[settingsDoc, originalSettings],
);
const anyDirty = isDirty || isModelsDirty || isSettingsDirty;
// Keep a ref to the latest any-doc dirty flag so the Tauri close-requested
// handler, captured once on mount, always sees the current value.
const isDirtyRef = useRef(anyDirty);
useEffect(() => {
isDirtyRef.current = anyDirty;
}, [anyDirty]);
const [paletteOpen, setPaletteOpen] = useState(false);
const save = async () => {
const count =
dirtyPaths.length + (isModelsDirty ? 1 : 0) + (isSettingsDirty ? 1 : 0);
setStatus("saving");
setError("");
const errs: string[] = [];
// Preferences — independent failure domain. On failure, leave prefs
// dirty so the user can retry without losing in-progress edits.
if (isDirty) {
try {
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
const args: { preferences: unknown; projectPath?: string } = { preferences: cleaned };
if (activeProjectPath) args.projectPath = activeProjectPath;
await invoke("save_preferences", args);
setOriginalPrefs(JSON.stringify(prefs));
} catch (e) {
errs.push(`Preferences: ${String(e)}`);
}
}
// models.json — independent. Pass expected_mtime_ms so GSD2 writing to
// this file concurrently gets caught (backend returns STALE: prefix).
if (isModelsDirty) {
try {
const args: {
models: unknown;
expectedMtimeMs: number | null;
projectPath?: string;
} = {
models: modelsDoc,
expectedMtimeMs: modelsMtime > 0 ? modelsMtime : null,
};
if (activeProjectPath) args.projectPath = activeProjectPath;
const newMtime = await invoke<number>("save_models", args);
setOriginalModels(JSON.stringify(modelsDoc));
setModelsMtime(newMtime);
} catch (e) {
const msg = String(e);
if (msg.includes("STALE:")) {
errs.push(
"Custom providers: file was changed on disk by GSD2. Reload the app to pick up external changes, then retry your edits.",
);
} else {
errs.push(`Custom providers: ${msg}`);
}
}
}
// settings.json — independent failure domain. Raw round-trip: do NOT run
// through cleanPrefs (would prune empty permission arrays etc.).
if (isSettingsDirty) {
try {
const args: {
settings: unknown;
expectedMtimeMs: number | null;
projectPath?: string;
} = {
settings: settingsDoc,
expectedMtimeMs: settingsMtime > 0 ? settingsMtime : null,
};
if (activeProjectPath) args.projectPath = activeProjectPath;
const newMtime = await invoke<number>("save_settings", args);
setOriginalSettings(JSON.stringify(settingsDoc));
setSettingsMtime(newMtime);
} catch (e) {
const msg = String(e);
if (msg.includes("STALE:")) {
errs.push(
"Agent settings: file was changed on disk by another process. Reload the app to pick up external changes, then retry your edits.",
);
} else {
errs.push(`Agent settings: ${msg}`);
}
}
}
if (errs.length > 0) {
setError(errs.join("\n"));
setStatus("error");
} else {
setSavedCount(count);
setStatus("saved");
setTimeout(() => {
setStatus("idle");
setSavedCount(0);
}, 2000);
}
};
const reset = () => {
setPrefs(JSON.parse(originalPrefs));
setModelsDoc(JSON.parse(originalModels));
setSettingsDoc(JSON.parse(originalSettings));
};
const [shareOpen, setShareOpen] = useState(false);
const [shareContent, setShareContent] = useState("");
const importPreset = async () => {
try {
setError("");
const picked = await open({
title: "Import preset",
multiple: false,
directory: false,
filters: [{ name: "GSD Preset", extensions: ["preset.md", "md"] }],
});
if (typeof picked !== "string" || !picked) return;
const loaded = await invoke<GSDPreferences>("import_preset", {
sourcePath: picked,
});
// Stay dirty on purpose — user reviews + clicks Save to commit.
setPrefs(loaded);
} catch (e) {
setError(String(e));
}
};
const exportPreset = async () => {
try {
setError("");
const target = await saveDialog({
title: "Export preset",
defaultPath: "gsd.preset.md",
filters: [{ name: "GSD Preset", extensions: ["preset.md", "md"] }],
});
if (!target) return;
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
await invoke<string>("export_preset", {
targetPath: target,
preferences: cleaned,
});
setStatus("saved");
setTimeout(() => setStatus("idle"), 2000);
} catch (e) {
setError(String(e));
}
};
const sharePreset = async () => {
try {
setError("");
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
const content = await invoke<string>("build_shareable_preset", {
preferences: cleaned,
});
setShareContent(content);
setShareOpen(true);
} catch (e) {
setError(String(e));
}
};
// Keep refs for shortcuts so handlers don't need to be re-memoized per render
const saveRef = useRef(save);
const resetRef = useRef(reset);
useEffect(() => {
saveRef.current = save;
resetRef.current = reset;
});
const shortcutCtx = useRef<{
section: SectionId;
setSection: (s: SectionId) => void;
setPaletteOpen: (v: boolean) => void;
}>({ section, setSection, setPaletteOpen });
useEffect(() => {
shortcutCtx.current = { section, setSection, setPaletteOpen };
});
// ⌘K palette · ⌘S save · ⌘⇧Z discard · [/] section prev/next
useShortcuts([
{
id: "palette",
key: "k",
mod: true,
allowInInput: true,
handler: (ev) => {
ev.preventDefault();
shortcutCtx.current.setPaletteOpen(true);
},
},
{
id: "save",
key: "s",
mod: true,
handler: (ev) => {
ev.preventDefault();
if (isDirtyRef.current) saveRef.current();
},
},
{
id: "discard",
key: "z",
mod: true,
shift: true,
handler: (ev) => {
ev.preventDefault();
if (isDirtyRef.current) resetRef.current();
},
},
{
id: "section-next",
key: "]",
handler: (ev) => {
ev.preventDefault();
const cur = SECTIONS.findIndex((s) => s.id === shortcutCtx.current.section);
const next = SECTIONS[(cur + 1) % SECTIONS.length];
if (next) shortcutCtx.current.setSection(next.id);
},
},
{
id: "section-prev",
key: "[",
handler: (ev) => {
ev.preventDefault();
const cur = SECTIONS.findIndex((s) => s.id === shortcutCtx.current.section);
const prev = SECTIONS[(cur - 1 + SECTIONS.length) % SECTIONS.length];
if (prev) shortcutCtx.current.setSection(prev.id);
},
},
]);
// ⌘K → field focus: when the palette picks a field, scroll it into view and
// flash a ring on the row. The section must render first (pendingFocus is
// set alongside setSection), so we defer to the next frame before querying
// the DOM. data-field-path lives on the Field wrapper in FormControls.tsx.
useEffect(() => {
if (!pendingFocus) return;
const path = pendingFocus;
const raf = requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(
`[data-field-path="${CSS.escape(path)}"]`,
);
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
el.classList.add("gsd-field-focus");
window.setTimeout(() => el.classList.remove("gsd-field-focus"), 1500);
}
setPendingFocus(null);
});
return () => cancelAnimationFrame(raf);
}, [pendingFocus, section]);
// Window close guard — prompt before closing with unsaved changes
useCloseRequested(
async (event) => {
if (!isDirtyRef.current) return;
const ok = confirm("You have unsaved changes. Close anyway?");
if (!ok) event.preventDefault();
},
[],
);
const browseProject = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: "Select Project Folder",
});
if (typeof selected === "string" && selected) {
setProjectPath(selected);
localStorage.setItem(LAST_PROJECT_KEY, selected);
// Update recent projects (most recent first, max 5)
const updated = [selected, ...recentProjects.filter((p) => p !== selected)].slice(0, 5);
setRecentProjects(updated);
saveRecentProjects(updated);
}
} catch (e) {
setError(String(e));
}
};
const selectScope = (s: Scope) => {
if (anyDirty) {
const ok = confirm("You have unsaved changes. Discard them and switch scope?");
if (!ok) return;
}
setScope(s);
localStorage.setItem(LAST_SCOPE_KEY, s);
};
const selectRecentProject = (path: string) => {
if (anyDirty) {
const ok = confirm("You have unsaved changes. Discard them and switch project?");
if (!ok) return;
}
setProjectPath(path);
localStorage.setItem(LAST_PROJECT_KEY, path);
};
const shortPath = (p: string) => {
const parts = p.split("/");
return parts[parts.length - 1] || p;
};
const renderSection = () => {
const props = { prefs, onChange: setPrefs };
switch (section) {
case "skills-library": return <SkillsLibrarySection projectPath={projectPath || undefined} />;
case "agents-library": return <AgentsLibrarySection projectPath={projectPath || undefined} />;
case "api-keys": return <ApiKeysSection />;
case "custom-providers":
return <CustomProvidersSection value={modelsDoc} onChange={setModelsDoc} />;
case "agent-settings":
return <AgentSettingsSection value={settingsDoc} onChange={setSettingsDoc} />;
case "general": return <GeneralSection {...props} />;
case "models": return <ModelsSection {...props} customModels={modelsDoc} />;
case "git": return <GitSection {...props} />;
case "skills": return <SkillsSection {...props} />;
case "budget": return <BudgetSection {...props} />;
case "notifications": return <NotificationsSection {...props} />;
case "parallel": return <ParallelSection {...props} />;
case "phases": return <PhasesSection {...props} />;
case "context": return <ContextSection {...props} />;
case "safety": return <SafetySection {...props} />;
case "verification": return <VerificationSection {...props} />;
case "discussion": return <DiscussionSection {...props} />;
case "hooks": return <HooksSection {...props} />;
case "routing": return <RoutingSection {...props} />;
case "cmux": return <CmuxSection {...props} />;
case "remote": return <RemoteSection {...props} />;
case "codebase": return <CodebaseSection {...props} />;
case "experimental": return <ExperimentalSection {...props} />;
}
};
// These sections are independent of preferences load state
const isLibrarySection =
section === "skills-library" || section === "agents-library" || section === "api-keys";
// Library sections with split panes need fixed-height flex layout
const needsFixedHeight = section === "skills-library" || section === "agents-library";
const isSkillsLibrary = isLibrarySection;
const needsProjectSelection = scope === "project" && !projectPath;
return (
<div className="flex h-screen overflow-hidden">
<Palette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onNavigate={(target, fieldPath) => {
setSection(target);
if (fieldPath) setPendingFocus(fieldPath);
}}
/>
<ShareModal
open={shareOpen}
content={shareContent}
onClose={() => setShareOpen(false)}
/>
<Sidebar active={section} onSelect={setSection} dirtySections={dirtySections} />
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar - scope selector */}
<header className="flex items-center justify-between px-6 py-3 border-b border-gsd-border bg-gsd-surface shrink-0 gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Scope pill */}
<div className="flex rounded-md border border-gsd-border overflow-hidden shrink-0">
<button
onClick={() => selectScope("global")}
className={`px-3 py-1 text-xs font-medium transition-colors ${
scope === "global"
? "bg-gsd-accent text-gsd-on-accent"
: "bg-gsd-bg text-gsd-text-dim hover:text-gsd-text"
}`}
>
Global
</button>
<button
onClick={() => selectScope("project")}
className={`px-3 py-1 text-xs font-medium transition-colors ${
scope === "project"
? "bg-gsd-accent text-gsd-on-accent"
: "bg-gsd-bg text-gsd-text-dim hover:text-gsd-text"
}`}
>
Project
</button>
</div>
{/* Project picker (when project scope active) */}
{scope === "project" && (
<div className="flex items-center gap-2 min-w-0">
<button
onClick={browseProject}
className="px-3 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover shrink-0"
>
Browse...
</button>
{projectPath && (
<span className="text-xs text-gsd-text truncate font-medium" title={projectPath}>
{shortPath(projectPath)}
</span>
)}
{recentProjects.length > 0 && (
<select
value=""
onChange={(e) => {
const v = e.target.value;
if (!v) return;
if (v === "__clear__") {
setRecentProjects([]);
saveRecentProjects([]);
return;
}
selectRecentProject(v);
}}
className="text-xs max-w-40"
>
<option value="">Recent...</option>
{recentProjects.map((p) => (
<option key={p} value={p}>
{shortPath(p)}
</option>
))}
<option disabled></option>
<option value="__clear__">Clear recent projects</option>
</select>
)}
</div>
)}
{/* File path (when loaded) */}
{filePath && !needsProjectSelection && (
<div className="text-xs text-gsd-text-dim truncate" title={filePath}>
{filePath}
</div>
)}
</div>
{/* Save/Discard/Export/Share/Theme buttons (hidden on Skills Library which has its own per-file save) */}
{!isSkillsLibrary && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => runUpdateCheck(true)}
disabled={updateChecking || updateInstalling}
title="Check for app updates"
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{updateChecking ? "Checking..." : "Updates"}
</button>
<ThemeToggle />
<div className="w-px h-5 bg-gsd-border mx-1" />
<button
onClick={importPreset}
disabled={needsProjectSelection}
title="Load preferences from a .preset.md file (review and save to commit)"
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Import
</button>
<button
onClick={exportPreset}
disabled={needsProjectSelection}
title="Export current preferences to a .preset.md file"
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Export
</button>
<button
onClick={sharePreset}
disabled={needsProjectSelection}
title="Copy a redacted shareable YAML block to clipboard"
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Share
</button>
<div className="w-px h-5 bg-gsd-border mx-1" />
{anyDirty && (
<button
onClick={reset}
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover transition-colors"
>
Discard
</button>
)}
<button
onClick={save}
disabled={!anyDirty || status === "saving" || needsProjectSelection}
className={`px-4 py-1.5 text-xs rounded-md font-medium transition-colors ${
anyDirty && !needsProjectSelection
? "bg-gsd-accent text-gsd-on-accent hover:bg-gsd-accent-hover"
: "bg-gsd-border text-gsd-text-dim cursor-not-allowed"
}`}
>
{status === "saving"
? "Saving..."
: status === "saved"
? savedCount > 0
? `Saved ${savedCount} change${savedCount === 1 ? "" : "s"}`
: "Saved"
: "Save"}
</button>
</div>
)}
</header>
{/* Update banner — shown when a newer version is available and the
user hasn't dismissed it this session. Install is explicit; we
never auto-download. */}
{updateInfo?.available && !updateDismissed && (
<div className="px-6 py-2 bg-gsd-accent/10 border-b border-gsd-accent/30 text-xs flex items-center justify-between gap-3">
<span className="text-gsd-text">
{updateInstalling
? `Installing v${updateInfo.version}… the app will relaunch when done.`
: `Update available: v${updateInfo.version}. Install now to relaunch with the new version.`}
</span>
<div className="flex items-center gap-2 shrink-0">
{!updateInstalling && (
<>
<button
onClick={() => setUpdateDismissed(true)}
className="px-2 py-1 rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover"
>
Later
</button>
<button
onClick={installUpdate}
className="px-3 py-1 rounded-md bg-gsd-accent text-gsd-on-accent hover:bg-gsd-accent-hover font-medium"
>
Install &amp; restart
</button>
</>
)}
</div>
</div>
)}
{/* Error banner */}
{error && (
<div className="px-6 py-2 bg-gsd-danger/10 border-b border-gsd-danger/30 text-gsd-danger text-xs flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError("")} className="ml-2 hover:text-red-300">dismiss</button>
</div>
)}
{/* Content */}
<main
className={`flex-1 px-6 py-5 ${
needsFixedHeight
? "overflow-hidden flex flex-col"
: "overflow-y-auto"
}`}
>
{needsProjectSelection && !isLibrarySection ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="text-4xl mb-3">📁</div>
<h2 className="text-lg font-semibold text-gsd-text mb-2">No project selected</h2>
<p className="text-sm text-gsd-text-dim mb-4 max-w-md">
Browse to a project folder to edit its <code className="text-xs bg-gsd-surface px-1.5 py-0.5 rounded">.gsd/preferences.md</code> file.
</p>
<button
onClick={browseProject}
className="px-4 py-2 text-sm rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover transition-colors"
>
Browse for Project...
</button>
</div>
) : (
renderSection()
)}
</main>
</div>
</div>
);
}
export { default } from "./App.desktop";
+24
View File
@@ -0,0 +1,24 @@
// GSD Pi Config - Web application routes
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { WebApp } from "./WebApp";
import { GalleryPage } from "./pages/GalleryPage";
import { OAuthCallbackPage } from "./pages/OAuthCallbackPage";
import { WizardPage } from "./pages/WizardPage";
export default function App() {
const basename = import.meta.env.BASE_URL.replace(/\/$/, "") || "/";
return (
<BrowserRouter basename={basename}>
<Routes>
<Route path="/" element={<WebApp />} />
<Route path="/gallery" element={<GalleryPage />} />
<Route path="/new" element={<WizardPage />} />
<Route path="/edit" element={<Navigate to="/" replace />} />
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
+1000
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
// GSD Pi Config - Desktop (Tauri) shell
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { ConfigBackendProvider } from "./platform/backend";
import { tauriBackend } from "./platform/tauriBackend";
import { ConfigApp } from "./ConfigApp";
export function DesktopApp() {
return (
<ConfigBackendProvider backend={tauriBackend}>
<ConfigApp variant="desktop" />
</ConfigBackendProvider>
);
}
+14
View File
@@ -0,0 +1,14 @@
// GSD Pi Config - Web shell
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { ConfigBackendProvider } from "./platform/backend";
import { webBackend } from "./platform/webBackend";
import { ConfigApp } from "./ConfigApp";
export function WebApp() {
return (
<ConfigBackendProvider backend={webBackend}>
<ConfigApp variant="web" />
</ConfigBackendProvider>
);
}
+13 -20
View File
@@ -1,41 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 80" width="192" height="80">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 64" width="168" height="64" role="img" aria-label="GSD Pi">
<defs>
<style>
.wordmark {
font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', 'Menlo', monospace;
font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
font-weight: 700;
letter-spacing: -0.02em;
fill: #7dcfff;
}
.badge {
font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', 'Menlo', monospace;
.badge-text {
font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
font-weight: 700;
fill: #000000;
fill: #0a0e14;
}
.bracket {
font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', 'Menlo', monospace;
font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
font-weight: 400;
fill: rgba(125, 207, 255, 0.45);
}
</style>
</defs>
<!-- Centered group: content spans 0→172, viewBox 192 → 10px padding on each side -->
<g transform="translate(10, 0)">
<!-- Left bracket -->
<text class="bracket" x="0" y="54" font-size="48" text-anchor="start">[</text>
<g transform="translate(8, 4)">
<text class="bracket" x="0" y="44" font-size="40" text-anchor="start">[</text>
<!-- GSD wordmark -->
<text class="wordmark" x="22" y="54" font-size="44">GSD</text>
<text class="wordmark" x="18" y="44" font-size="36">GSD</text>
<rect x="18" y="50" width="70" height="2.5" fill="rgba(125, 207, 255, 0.28)" rx="1"/>
<!-- Underscore cursor accent under GSD -->
<rect x="22" y="62" width="82" height="3" fill="rgba(125, 207, 255, 0.3)"/>
<rect x="96" y="14" width="32" height="32" rx="6" fill="#7dcfff"/>
<text class="badge-text" x="112" y="38" font-size="22" text-anchor="middle">π</text>
<!-- "2" badge — cyan square placed tight against GSD -->
<rect x="112" y="22" width="36" height="36" rx="6" fill="#7dcfff"/>
<text class="badge" x="130" y="50" font-size="28" text-anchor="middle">2</text>
<!-- Right bracket — anchored at end so its glyph sits tight against the badge -->
<text class="bracket" x="172" y="54" font-size="48" text-anchor="end">]</text>
<text class="bracket" x="152" y="44" font-size="40" text-anchor="end">]</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+39
View File
@@ -0,0 +1,39 @@
// GSD Pi Config - Open GSD brand mark (opengsd.net)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import opengsdLogo from "../assets/opengsd-logo.png";
interface BrandMarkProps {
/** sm: shell header · md: desktop sidebar */
size?: "sm" | "md";
/** Secondary line under the title */
subtitle?: string;
className?: string;
}
export function BrandMark({ size = "sm", subtitle, className = "" }: BrandMarkProps) {
const img = size === "sm" ? "h-8 w-8" : "h-9 w-9";
return (
<span className={`inline-flex items-center gap-2.5 min-w-0 ${className}`}>
<img
src={opengsdLogo}
alt=""
className={`${img} shrink-0 rounded-sm object-contain`}
width={36}
height={36}
decoding="async"
/>
<span className="flex min-w-0 flex-col justify-center leading-none">
<span className="text-sm font-semibold tracking-tight text-gsd-text truncate">
Open GSD
</span>
{subtitle && (
<span className="text-[10px] text-gsd-text-muted mt-1 tracking-wide truncate">
{subtitle}
</span>
)}
</span>
</span>
);
}
+195 -30
View File
@@ -1,7 +1,7 @@
// GSD2 Config - Reusable Form Controls
// GSD Pi Config - Reusable Form Controls
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { getField, type FieldPath } from "../lib/fields";
interface FieldProps {
@@ -30,26 +30,25 @@ export function Field({ label, description, children, path, value }: FieldProps)
return (
<div
className="flex items-start justify-between gap-4 py-3 border-b border-gsd-border last:border-b-0"
className="flex flex-col gap-3 py-3 border-b border-gsd-border last:border-b-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
data-invalid={error ? "" : undefined}
data-field-path={path}
>
<div className="flex-1 min-w-0">
<label className="flex items-center gap-1.5 text-sm font-medium text-gsd-text">
<span>{label}</span>
<div className="min-w-0 flex-1">
<label className="inline-flex max-w-full items-center gap-1.5 text-sm font-medium text-gsd-text">
<span className="min-w-0">{label}</span>
{meta?.hint && (
<span className="relative inline-flex group">
<span
tabIndex={0}
role="button"
<span className="group relative shrink-0">
<button
type="button"
aria-label={meta.hint}
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full text-[9px] font-bold text-gsd-text-dim border border-gsd-border cursor-help hover:text-gsd-text hover:border-gsd-border-strong transition-colors focus:outline-none focus:ring-1 focus:ring-gsd-accent"
className="gsd-hint-trigger relative z-[1] flex h-4 w-4 items-center justify-center rounded-full border border-gsd-border text-[9px] font-bold leading-none text-gsd-text-dim cursor-help hover:border-gsd-border-strong hover:text-gsd-text transition-[color,border-color,transform] active:scale-[0.96] focus:outline-none focus-visible:ring-0"
>
?
</span>
</button>
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full mt-1.5 z-50 w-64 max-w-[min(16rem,calc(100vw-2rem))] rounded-md border border-gsd-border-strong bg-gsd-surface-solid px-2.5 py-1.5 text-xs font-normal leading-snug text-gsd-text shadow-xl opacity-0 translate-y-[-2px] transition-[opacity,transform] duration-100 origin-top-left group-hover:opacity-100 group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:translate-y-0"
className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 w-64 max-w-[min(16rem,calc(100vw-2rem))] rounded-md border border-gsd-border-strong bg-gsd-surface-solid px-2.5 py-1.5 text-xs font-normal leading-snug text-gsd-text shadow-xl opacity-0 translate-y-[-2px] transition-[opacity,transform] duration-100 origin-top-left group-hover:opacity-100 group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:translate-y-0"
>
{meta.hint}
</span>
@@ -57,11 +56,13 @@ export function Field({ label, description, children, path, value }: FieldProps)
)}
</label>
{description && (
<p className="mt-0.5 text-xs text-gsd-text-dim">{description}</p>
<p className="gsd-prose mt-0.5 text-xs leading-relaxed text-gsd-text-dim">{description}</p>
)}
{error && <p className="mt-1 text-xs text-gsd-danger">{error}</p>}
</div>
<div className="shrink-0">{children}</div>
<div className="gsd-field-control [&_select]:w-full [&_input]:w-full [&_input]:max-w-full sm:[&_select]:w-52 sm:[&_input]:w-52">
{children}
</div>
</div>
);
}
@@ -78,15 +79,19 @@ export function Toggle({ checked, onChange }: ToggleProps) {
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors ${
checked ? "bg-gsd-accent" : "bg-gsd-border"
}`}
className="inline-flex min-h-10 min-w-10 shrink-0 items-center justify-center self-start rounded-full transition-transform active:scale-[0.96] sm:self-center"
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
checked ? "translate-x-4.5 ml-0" : "translate-x-0.5"
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors ${
checked ? "bg-gsd-accent" : "bg-gsd-border"
}`}
/>
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
checked ? "translate-x-4.5 ml-0" : "translate-x-0.5"
}`}
/>
</span>
</button>
);
}
@@ -106,7 +111,7 @@ export function SelectField<T extends string>({
options,
placeholder = "Default",
allowEmpty = true,
className = "w-52",
className = "w-full sm:w-52",
}: SelectFieldProps<T>) {
return (
<select
@@ -124,6 +129,166 @@ export function SelectField<T extends string>({
);
}
interface LabeledSelectOption {
value: string;
label: string;
}
interface LabeledSelectFieldProps {
value: string | undefined;
onChange: (value: string | undefined) => void;
options: readonly LabeledSelectOption[];
placeholder?: string;
className?: string;
}
/** Dropdown with human-readable labels (value may differ from display text). */
export function LabeledSelectField({
value,
onChange,
options,
placeholder = "Default",
className = "w-full sm:w-52",
}: LabeledSelectFieldProps) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
className={className}
>
<option value="">{placeholder}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
interface MultiSelectFieldProps {
values: string[];
onChange: (values: string[]) => void;
options: readonly { value: string; label: string }[];
className?: string;
placeholder?: string;
}
/** Checkbox dropdown for choosing multiple values from a fixed list. */
export function MultiSelectField({
values,
onChange,
options,
className = "w-full sm:w-64",
placeholder = "Select…",
}: MultiSelectFieldProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const listId = useId();
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (rootRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const toggle = (value: string) => {
if (values.includes(value)) {
onChange(values.filter((v) => v !== value));
} else {
onChange([...values, value]);
}
};
const labelFor = (value: string) =>
options.find((o) => o.value === value)?.label ?? value;
const summary =
values.length === 0
? placeholder
: values.length <= 2
? values.map(labelFor).join(", ")
: `${values.length} selected`;
return (
<div ref={rootRef} className={`relative text-xs ${className}`}>
<button
type="button"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listId}
onClick={() => setOpen((o) => !o)}
className="gsd-dropdown-trigger"
>
<span className={`min-w-0 flex-1 truncate text-left ${values.length === 0 ? "text-gsd-text-dim" : "text-gsd-text"}`}>
{summary}
</span>
<span className="shrink-0 text-[10px] text-gsd-text-dim" aria-hidden>
{open ? "▴" : "▾"}
</span>
</button>
{open && (
<div
id={listId}
role="listbox"
aria-multiselectable="true"
className="gsd-dropdown-panel"
>
{options.map((opt) => {
const checked = values.includes(opt.value);
return (
<label
key={opt.value}
role="option"
aria-selected={checked}
className="gsd-dropdown-option"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(opt.value)}
className="shrink-0"
/>
<span className="min-w-0 truncate">{opt.label}</span>
</label>
);
})}
</div>
)}
{values.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{values.map((v) => (
<span key={v} className="gsd-chip">
<span className="max-w-[12rem] truncate">{labelFor(v)}</span>
<button
type="button"
aria-label={`Remove ${labelFor(v)}`}
onClick={() => toggle(v)}
className="gsd-chip-remove"
>
×
</button>
</span>
))}
</div>
)}
</div>
);
}
/**
* Combo input — a dropdown of known values plus a free-text field.
* Useful when there's a recommended list but the user can also type custom.
@@ -141,7 +306,7 @@ export function ComboField({
onChange,
options,
placeholder = "Select or type",
className = "w-52",
className = "w-full sm:w-52",
}: ComboFieldProps) {
const listId = `combo-${Math.random().toString(36).slice(2)}`;
return (
@@ -165,9 +330,9 @@ export function ComboField({
/**
* Provider+Model picker. Each option represents a specific auth/routing path
* (e.g. "Claude Code CLI" vs "Anthropic API" vs "AWS Bedrock") paired with a
* (e.g. "OpenAI API" vs "Anthropic API" vs "OpenRouter") paired with a
* model ID. The emitted value is a `provider/model` qualified string that
* GSD-2 understands.
* GSD Pi understands.
*/
import type { ProviderCatalog } from "../constants";
@@ -186,7 +351,7 @@ export function ModelPicker({
onChange,
catalog,
placeholder = "Default",
className = "w-64",
className = "w-full sm:w-64",
}: ModelPickerProps) {
// Build set of all qualified `provider/model` keys we know
const knownQualified = new Set<string>();
@@ -256,7 +421,7 @@ export function ModelChain({
chain,
onChange,
catalog,
className = "w-64",
className = "w-full sm:w-64",
}: ModelChainProps) {
// Local state lets us keep trailing empty rows visible while the user is
// picking. The parent only ever sees the filtered (non-empty) chain, so
@@ -415,7 +580,7 @@ export function TextField({
value,
onChange,
placeholder,
className = "w-52",
className = "w-full sm:w-52",
}: TextFieldProps) {
// Defensive coercion: some preference keys can arrive as numbers when a
// YAML file stored them unquoted (e.g. a Discord snowflake `channel_id`).
@@ -492,9 +657,9 @@ interface SectionHeaderProps {
export function SectionHeader({ title, description }: SectionHeaderProps) {
return (
<div className="mb-4">
<h2 className="text-lg font-semibold text-gsd-text">{title}</h2>
<h2 className="gsd-heading text-lg font-semibold text-gsd-text">{title}</h2>
{description && (
<p className="mt-1 text-sm text-gsd-text-dim">{description}</p>
<p className="gsd-prose mt-1 text-sm text-gsd-text-dim">{description}</p>
)}
</div>
);
+314
View File
@@ -0,0 +1,314 @@
import { useCallback, useEffect, useState } from "react";
import {
readJsonConfigFromFile,
readPreferencesFromFile,
type ImportedWorkspace,
} from "../lib/importWorkspace";
import { pickFile } from "../lib/pickFile";
import { btn, btnPrimary, modalPanel } from "../lib/uiClasses";
import type { GSDModelsConfig, GSDPreferences } from "../types";
interface ImportPreferencesModalProps {
open: boolean;
onClose: () => void;
onImport: (data: ImportedWorkspace) => void;
/** Cloud web: upload copy; desktop: browse on disk. */
variant?: "web" | "desktop";
/** When set, use native file dialogs instead of the browser picker. */
pickPreferencesFile?: () => Promise<ImportedWorkspace | null>;
pickModelsFile?: () => Promise<Pick<ImportedWorkspace, "models" | "modelsFileName"> | null>;
pickSettingsFile?: () => Promise<Pick<ImportedWorkspace, "settings" | "settingsFileName"> | null>;
}
function fileLabel(name: string | undefined, placeholder: string): string {
return name ?? placeholder;
}
export function ImportPreferencesModal({
open,
onClose,
onImport,
variant = "desktop",
pickPreferencesFile,
pickModelsFile,
pickSettingsFile,
}: ImportPreferencesModalProps) {
const isWeb = variant === "web";
const pickLabel = "Browse…";
const [prefsFile, setPrefsFile] = useState<File | null>(null);
const [modelsFile, setModelsFile] = useState<File | null>(null);
const [settingsFile, setSettingsFile] = useState<File | null>(null);
const [nativeLabels, setNativeLabels] = useState<{
preferences?: string;
models?: string;
settings?: string;
}>({});
const [nativePrefs, setNativePrefs] = useState<GSDPreferences | null>(null);
const [nativeModels, setNativeModels] = useState<GSDModelsConfig | null>(null);
const [nativeSettings, setNativeSettings] = useState<ImportedWorkspace["settings"] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const useNative = Boolean(pickPreferencesFile);
const reset = useCallback(() => {
setPrefsFile(null);
setModelsFile(null);
setSettingsFile(null);
setNativeLabels({});
setNativePrefs(null);
setNativeModels(null);
setNativeSettings(null);
setError(null);
setBusy(false);
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [onClose, reset]);
const browsePreferences = useCallback(async () => {
setError(null);
if (pickPreferencesFile) {
const result = await pickPreferencesFile();
if (!result?.preferences) return;
setNativePrefs(result.preferences);
setNativeLabels((l) => ({
...l,
preferences: result.preferencesFileName ?? "preferences.md",
}));
return;
}
const file = await pickFile(".md,text/markdown,text/plain");
if (file) setPrefsFile(file);
}, [pickPreferencesFile]);
const browseModels = useCallback(async () => {
setError(null);
if (pickModelsFile) {
const result = await pickModelsFile();
if (!result?.models) return;
setNativeModels(result.models);
setNativeLabels((l) => ({
...l,
models: result.modelsFileName ?? "models.json",
}));
return;
}
const file = await pickFile(".json,application/json");
if (file) setModelsFile(file);
}, [pickModelsFile]);
const browseSettings = useCallback(async () => {
setError(null);
if (pickSettingsFile) {
const result = await pickSettingsFile();
if (!result?.settings) return;
setNativeSettings(result.settings);
setNativeLabels((l) => ({
...l,
settings: result.settingsFileName ?? "settings.json",
}));
return;
}
const file = await pickFile(".json,application/json");
if (file) setSettingsFile(file);
}, [pickSettingsFile]);
const canImport = useNative
? nativePrefs != null
: prefsFile != null;
const handleImport = useCallback(async () => {
if (!canImport) return;
setBusy(true);
setError(null);
try {
const payload: ImportedWorkspace = {};
if (useNative && nativePrefs) {
payload.preferences = nativePrefs;
payload.preferencesFileName = nativeLabels.preferences;
if (nativeModels) {
payload.models = nativeModels;
payload.modelsFileName = nativeLabels.models;
}
if (nativeSettings) {
payload.settings = nativeSettings;
payload.settingsFileName = nativeLabels.settings;
}
} else {
if (!prefsFile) return;
payload.preferences = await readPreferencesFromFile(prefsFile);
payload.preferencesFileName = prefsFile.name;
if (modelsFile) {
payload.models = (await readJsonConfigFromFile(
modelsFile,
)) as ImportedWorkspace["models"];
payload.modelsFileName = modelsFile.name;
}
if (settingsFile) {
payload.settings = await readJsonConfigFromFile(settingsFile);
payload.settingsFileName = settingsFile.name;
}
}
onImport(payload);
handleClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}, [
canImport,
useNative,
nativePrefs,
nativeLabels,
nativeModels,
nativeSettings,
prefsFile,
modelsFile,
settingsFile,
onImport,
handleClose,
]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, handleClose]);
if (!open) return null;
const prefsLabel = useNative
? fileLabel(nativeLabels.preferences, "No file selected")
: fileLabel(prefsFile?.name, "No file selected");
const modelsLabel = useNative
? fileLabel(nativeLabels.models, "Optional")
: fileLabel(modelsFile?.name, "Optional");
const settingsLabel = useNative
? fileLabel(nativeLabels.settings, "Optional")
: fileLabel(settingsFile?.name, "Optional");
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="import-prefs-title"
onClick={handleClose}
>
<div
className={`w-full max-w-md flex flex-col overflow-hidden ${modalPanel}`}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-gsd-border shrink-0">
<h2 id="import-prefs-title" className="gsd-heading text-sm font-semibold text-gsd-text">
Import preferences
</h2>
<p className="gsd-prose mt-1 text-xs text-gsd-text-dim">
{isWeb ? (
<>
Choose files from your computer to edit in this browser session. Files stay on
your machine until you use <strong className="font-medium text-gsd-text">Download files</strong>{" "}
to save copies for GSD Pi (typically{" "}
<code className="text-[10px]">~/.gsd/preferences.md</code>,{" "}
<code className="text-[10px]">~/.gsd/agent/models.json</code>,{" "}
<code className="text-[10px]">settings.json</code>).
</>
) : (
<>
Load your existing GSD config from disk into this workspace. Usually{" "}
<code className="text-[10px]">~/.gsd/preferences.md</code> with optional{" "}
<code className="text-[10px]">models.json</code> and{" "}
<code className="text-[10px]">settings.json</code> (or under{" "}
<code className="text-[10px]">.gsd/</code> in a project).
</>
)}
</p>
</div>
<div className="px-5 py-4 space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gsd-text">
preferences.md{" "}
<span className="text-gsd-accent font-normal">required</span>
</div>
<div className="text-[10px] text-gsd-text-dim truncate" title={prefsLabel}>
{prefsLabel}
</div>
</div>
<button
type="button"
onClick={() => void browsePreferences()}
className={`${btn} shrink-0`}
>
{pickLabel}
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gsd-text">
models.json{" "}
<span className="text-gsd-text-dim font-normal">optional</span>
</div>
<div className="text-[10px] text-gsd-text-dim truncate" title={modelsLabel}>
{modelsLabel}
</div>
</div>
<button
type="button"
onClick={() => void browseModels()}
className={`${btn} shrink-0`}
>
{pickLabel}
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gsd-text">
settings.json{" "}
<span className="text-gsd-text-dim font-normal">optional</span>
</div>
<div className="text-[10px] text-gsd-text-dim truncate" title={settingsLabel}>
{settingsLabel}
</div>
</div>
<button
type="button"
onClick={() => void browseSettings()}
className={`${btn} shrink-0`}
>
{pickLabel}
</button>
</div>
{error && (
<p className="text-xs text-gsd-danger" role="alert">
{error}
</p>
)}
</div>
<div className="px-5 py-4 border-t border-gsd-border flex justify-end gap-2 shrink-0">
<button type="button" onClick={handleClose} className={btn}>
Cancel
</button>
<button
type="button"
disabled={!canImport || busy}
onClick={() => void handleImport()}
className={btnPrimary}
>
{busy ? "Importing…" : isWeb ? "Import into editor" : "Import into workspace"}
</button>
</div>
</div>
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
// GSD Pi Config - Load preset (gallery or file)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { loadPreferencesFromText } from "../lib/preferencesCore";
import {
fetchPresetIndex,
fetchPresetMarkdown,
type PresetIndexEntry,
} from "../lib/presetsCatalog";
import { useConfigBackend } from "../platform/backend";
import type { GSDPreferences } from "../types";
import { btn, modalPanel } from "../lib/uiClasses";
export interface LoadedPresetMeta {
title?: string;
slug?: string;
description?: string;
}
interface LoadPresetModalProps {
open: boolean;
onClose: () => void;
onLoaded: (prefs: GSDPreferences, meta?: LoadedPresetMeta) => void;
}
export function LoadPresetModal({ open, onClose, onLoaded }: LoadPresetModalProps) {
const backend = useConfigBackend();
const [entries, setEntries] = useState<PresetIndexEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [loadingSlug, setLoadingSlug] = useState<string | null>(null);
const loadIndex = useCallback(async () => {
setLoading(true);
setError("");
try {
const index = await fetchPresetIndex();
setEntries(index.presets ?? []);
} catch (e) {
setError(String(e));
setEntries([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) {
setQuery("");
setError("");
setLoadingSlug(null);
return;
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
void loadIndex();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, loadIndex]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
e.description.toLowerCase().includes(q) ||
e.slug.toLowerCase().includes(q) ||
e.tags.some((t) => t.toLowerCase().includes(q)),
);
}, [entries, query]);
const loadEntry = async (entry: PresetIndexEntry) => {
setLoadingSlug(entry.slug);
setError("");
try {
const text = await fetchPresetMarkdown(entry.path);
const prefs = loadPreferencesFromText(text);
onLoaded(prefs, {
title: entry.title,
slug: entry.slug,
description: entry.description,
});
onClose();
} catch (e) {
setError(String(e));
} finally {
setLoadingSlug(null);
}
};
const loadFromFile = async () => {
setError("");
try {
const loaded = await backend.importPresetDialog();
if (loaded) {
onLoaded(loaded);
onClose();
}
} catch (e) {
setError(String(e));
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className={`w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden ${modalPanel}`}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-gsd-border shrink-0">
<h2 className="gsd-heading text-sm font-semibold text-gsd-text">Load preset</h2>
<p className="gsd-prose mt-1 text-xs text-gsd-text-dim">
Choose a community preset or load a <code className="text-[10px]">.preset.md</code>{" "}
file from your computer. Review changes, then Save to apply.
</p>
</div>
<div className="px-5 py-3 border-b border-gsd-border flex gap-2 shrink-0">
<input
type="search"
placeholder="Search presets..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 text-sm"
autoFocus
/>
<button
type="button"
onClick={() => void loadFromFile()}
className={`${btn} shrink-0`}
>
From file
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3 min-h-0">
{error && (
<p className="text-xs text-gsd-danger mb-3">{error}</p>
)}
{loading && (
<p className="text-xs text-gsd-text-dim">Loading gallery</p>
)}
{!loading && filtered.length === 0 && !error && (
<p className="text-xs text-gsd-text-dim">
No presets in the gallery index. Try{" "}
<button
type="button"
className="text-gsd-accent underline"
onClick={() => void loadFromFile()}
>
loading from file
</button>
.
</p>
)}
<ul className="space-y-2">
{filtered.map((entry) => (
<li key={entry.slug}>
<button
type="button"
disabled={loadingSlug !== null}
onClick={() => void loadEntry(entry)}
className="w-full min-h-10 text-left p-3 rounded-md border border-gsd-border hover:border-gsd-accent/50 hover:bg-gsd-surface-hover transition-[border-color,background-color,transform] active:scale-[0.96] disabled:opacity-50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gsd-text">{entry.title}</span>
{loadingSlug === entry.slug && (
<span className="text-[10px] text-gsd-text-dim">Loading</span>
)}
</div>
{entry.description && (
<p className="text-xs text-gsd-text-dim mt-1 line-clamp-2">
{entry.description}
</p>
)}
{entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{entry.tags.map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 rounded bg-gsd-bg border border-gsd-border text-gsd-text-muted"
>
{tag}
</span>
))}
</div>
)}
</button>
</li>
))}
</ul>
</div>
<div className="px-5 py-3 border-t border-gsd-border flex items-center justify-between gap-3 shrink-0">
{backend.isWeb() ? (
<Link
to="/gallery"
className="text-xs text-gsd-accent hover:text-gsd-accent-hover"
onClick={onClose}
>
Browse full gallery
</Link>
) : (
<span className="text-xs text-gsd-text-dim">Community presets</span>
)}
<button type="button" onClick={onClose} className={btn}>
Cancel
</button>
</div>
</div>
</div>
);
}
+15 -6
View File
@@ -1,4 +1,4 @@
// GSD Setup - ⌘K Command Palette
// GSD Pi Config - ⌘K Command Palette
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Fuzzy-free substring + token-prefix search across the field registry and
@@ -10,10 +10,14 @@ import { ALL_FIELD_PATHS, getField } from "../lib/fields";
import type { FieldMeta } from "../lib/fields";
import { SECTION_GROUPS, type SectionId } from "./Sidebar";
import type { SectionGroup } from "./Sidebar";
import { modalPanel } from "../lib/uiClasses";
interface Props {
open: boolean;
onClose: () => void;
onNavigate: (section: SectionId, fieldPath?: string) => void;
sectionGroups?: readonly SectionGroup[];
}
type Result =
@@ -67,7 +71,12 @@ function scoreSection(label: string, id: string, q: string): number {
return 0;
}
export function Palette({ open, onClose, onNavigate }: Props) {
export function Palette({
open,
onClose,
onNavigate,
sectionGroups = SECTION_GROUPS,
}: Props) {
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
@@ -76,10 +85,10 @@ export function Palette({ open, onClose, onNavigate }: Props) {
// Flat list of { id, label, group } for all sections
const allSections = useMemo(
() =>
SECTION_GROUPS.flatMap((g) =>
sectionGroups.flatMap((g) =>
g.items.map((it) => ({ id: it.id as SectionId, label: it.label, group: g.label })),
),
[],
[sectionGroups],
);
const results: Result[] = useMemo(() => {
@@ -160,7 +169,7 @@ export function Palette({ open, onClose, onNavigate }: Props) {
onClick={onClose}
>
<div
className="w-full max-w-xl bg-gsd-surface-solid border border-gsd-border rounded-lg shadow-2xl overflow-hidden"
className={`w-full max-w-xl overflow-hidden ${modalPanel}`}
onClick={(e) => e.stopPropagation()}
>
<div className="px-4 py-3 border-b border-gsd-border">
@@ -188,7 +197,7 @@ export function Palette({ open, onClose, onNavigate }: Props) {
data-idx={i}
onMouseEnter={() => setCursor(i)}
onClick={() => pick(r)}
className={`px-4 py-2 cursor-pointer text-sm flex items-center justify-between gap-3 ${
className={`min-h-10 px-4 py-2 cursor-pointer text-sm flex items-center justify-between gap-3 transition-[background-color,color,transform] active:scale-[0.96] ${
active
? "bg-gsd-accent-dim text-gsd-accent"
: "text-gsd-text hover:bg-gsd-surface-hover"
+128
View File
@@ -0,0 +1,128 @@
// GSD Pi Config - Section renderers shared by desktop and web
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ReactNode } from "react";
import type { GSDModelsConfig, GSDPreferences } from "../types";
import { MODEL_CATALOG } from "../constants";
import { mergeCustomProviders } from "../lib/customProviders";
import { GeneralSection } from "./sections/GeneralSection";
import { ModelsSection } from "./sections/ModelsSection";
import { GitSection } from "./sections/GitSection";
import { SkillsSection } from "./sections/SkillsSection";
import { BudgetSection } from "./sections/BudgetSection";
import { NotificationsSection } from "./sections/NotificationsSection";
import { ParallelSection } from "./sections/ParallelSection";
import { PhasesSection } from "./sections/PhasesSection";
import { ContextSection } from "./sections/ContextSection";
import { SafetySection } from "./sections/SafetySection";
import { VerificationSection } from "./sections/VerificationSection";
import { DiscussionSection } from "./sections/DiscussionSection";
import { HooksSection } from "./sections/HooksSection";
import { RoutingSection } from "./sections/RoutingSection";
import { CmuxSection } from "./sections/CmuxSection";
import { RemoteSection } from "./sections/RemoteSection";
import { CodebaseSection } from "./sections/CodebaseSection";
import { ExperimentalSection } from "./sections/ExperimentalSection";
import { UokSection } from "./sections/UokSection";
import { GitHubSection } from "./sections/GitHubSection";
import { WorkspaceSection } from "./sections/WorkspaceSection";
import { McpSection } from "./sections/McpSection";
import { SkillsLibrarySection } from "./sections/SkillsLibrarySection";
import { AgentsLibrarySection } from "./sections/AgentsLibrarySection";
import { ApiKeysSection } from "./sections/ApiKeysSection";
import { CustomProvidersSection } from "./sections/CustomProvidersSection";
import { AgentSettingsSection } from "./sections/AgentSettingsSection";
import type { SectionId } from "./Sidebar";
export interface SectionRenderContext {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
projectPath?: string;
modelsDoc?: GSDModelsConfig;
onModelsChange?: (m: GSDModelsConfig) => void;
settingsDoc?: Record<string, unknown>;
onSettingsChange?: (s: Record<string, unknown>) => void;
isWeb?: boolean;
}
export function renderPreferencesSection(
section: SectionId,
ctx: SectionRenderContext,
): ReactNode {
const props = { prefs: ctx.prefs, onChange: ctx.onChange };
const customModels = ctx.modelsDoc ?? {};
const { catalog: modelCatalog } = mergeCustomProviders(MODEL_CATALOG, customModels);
switch (section) {
case "skills-library":
return <SkillsLibrarySection projectPath={ctx.projectPath} />;
case "agents-library":
return <AgentsLibrarySection projectPath={ctx.projectPath} />;
case "api-keys":
return <ApiKeysSection />;
case "custom-providers":
return (
<CustomProvidersSection
value={ctx.modelsDoc ?? {}}
onChange={ctx.onModelsChange ?? (() => {})}
/>
);
case "agent-settings":
return (
<AgentSettingsSection
value={ctx.settingsDoc ?? {}}
onChange={ctx.onSettingsChange ?? (() => {})}
modelCatalog={modelCatalog}
/>
);
case "general":
return <GeneralSection {...props} />;
case "models":
return (
<ModelsSection
{...props}
customModels={customModels}
/>
);
case "git":
return <GitSection {...props} />;
case "skills":
return <SkillsSection {...props} />;
case "budget":
return <BudgetSection {...props} />;
case "notifications":
return <NotificationsSection {...props} />;
case "parallel":
return <ParallelSection {...props} modelCatalog={modelCatalog} />;
case "phases":
return <PhasesSection {...props} />;
case "context":
return <ContextSection {...props} />;
case "safety":
return <SafetySection {...props} />;
case "verification":
return <VerificationSection {...props} />;
case "discussion":
return <DiscussionSection {...props} />;
case "hooks":
return <HooksSection {...props} modelCatalog={modelCatalog} />;
case "routing":
return <RoutingSection {...props} modelCatalog={modelCatalog} />;
case "cmux":
return <CmuxSection {...props} />;
case "remote":
return <RemoteSection {...props} />;
case "github":
return <GitHubSection {...props} />;
case "uok":
return <UokSection {...props} />;
case "workspace":
return <WorkspaceSection {...props} />;
case "mcp":
return <McpSection {...props} />;
case "codebase":
return <CodebaseSection {...props} />;
case "experimental":
return <ExperimentalSection {...props} modelCatalog={modelCatalog} />;
}
}
+9 -13
View File
@@ -1,7 +1,8 @@
// GSD2 Config - Share Modal (redacted preset copy-to-clipboard)
// GSD Pi Config - Share Modal (redacted preset copy-to-clipboard)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useEffect, useState } from "react";
import { btn, btnPrimary, modalPanel } from "../lib/uiClasses";
interface ShareModalProps {
open: boolean;
@@ -59,21 +60,22 @@ export function ShareModal({ open, content, onClose }: ShareModalProps) {
onClick={onClose}
>
<div
className="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-lg border border-gsd-border bg-gsd-surface-solid shadow-xl"
className={`w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden ${modalPanel}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-gsd-border">
<div>
<h2 className="text-sm font-semibold text-gsd-text">Share preset</h2>
<p className="mt-1 text-xs text-gsd-text-dim">
<h2 className="gsd-heading text-sm font-semibold text-gsd-text">Share preset</h2>
<p className="gsd-prose mt-1 text-xs text-gsd-text-dim">
Values under keys containing <code>key</code>, <code>token</code>,{" "}
<code>secret</code>, or <code>password</code> are redacted. Review the block
below before copying this is exactly what will land on your clipboard.
</p>
</div>
<button
type="button"
onClick={onClose}
className="text-gsd-text-dim hover:text-gsd-text text-lg leading-none"
className={`${btn} min-w-10 !px-0 text-lg leading-none`}
aria-label="Close"
>
×
@@ -87,16 +89,10 @@ export function ShareModal({ open, content, onClose }: ShareModalProps) {
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-gsd-border">
<button
onClick={onClose}
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover transition-colors"
>
<button type="button" onClick={onClose} className={btn}>
Cancel
</button>
<button
onClick={copy}
className="px-4 py-1.5 text-xs rounded-md font-medium bg-gsd-accent text-gsd-on-accent hover:bg-gsd-accent-hover transition-colors"
>
<button type="button" onClick={() => void copy()} className={btnPrimary}>
{copied ? "Copied!" : "Copy to clipboard"}
</button>
</div>
+73 -19
View File
@@ -1,7 +1,7 @@
// GSD2 Config - Sidebar Navigation
// GSD Pi Config - Sidebar Navigation
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import gsdLogo from "../assets/gsd-logo.svg";
import { BrandMark } from "./BrandMark";
export const SECTION_GROUPS = [
{
@@ -54,6 +54,10 @@ export const SECTION_GROUPS = [
items: [
{ id: "cmux", label: "CMux" },
{ id: "remote", label: "Remote Questions" },
{ id: "github", label: "GitHub Sync" },
{ id: "uok", label: "UOK" },
{ id: "workspace", label: "Workspace" },
{ id: "mcp", label: "Claude MCP" },
],
},
{
@@ -72,28 +76,69 @@ export const SECTIONS: readonly AllItems[] = SECTION_GROUPS.flatMap(
export type SectionId = AllItems["id"];
export type SectionGroup = {
label: string;
items: readonly { id: SectionId; label: string }[];
};
export function sectionLabel(
id: SectionId,
groups: readonly SectionGroup[],
): string {
for (const group of groups) {
const item = group.items.find((i) => i.id === id);
if (item) return item.label;
}
return id;
}
interface SidebarProps {
active: SectionId;
onSelect: (id: SectionId) => void;
/** Web: no logo block (branding lives in WebShell). */
variant?: "desktop" | "web";
/** Extra classes (e.g. mobile drawer positioning). */
className?: string;
/** Sections with unsaved changes — render a dirty dot next to each. */
dirtySections?: Set<SectionId>;
/** Override groups (e.g. web hides desktop-only sections). */
sectionGroups?: readonly SectionGroup[] | typeof SECTION_GROUPS;
/** Optional footer link (web: back to gallery). */
footerLink?: { label: string; href: string };
}
export function Sidebar({ active, onSelect, dirtySections }: SidebarProps) {
export function Sidebar({
active,
onSelect,
variant = "desktop",
dirtySections,
sectionGroups = SECTION_GROUPS,
footerLink,
className = "",
}: SidebarProps) {
const isWeb = variant === "web";
return (
<nav className="w-56 shrink-0 bg-gsd-bg border-r border-gsd-border overflow-y-auto relative z-10">
<div className="px-5 pt-6 pb-5 border-b border-gsd-border flex flex-col items-center">
<img
src={gsdLogo}
alt="GSD2"
className="h-11 w-auto mb-2 drop-shadow-[0_0_24px_rgba(125,207,255,0.35)]"
/>
<p className="text-[10px] text-gsd-text-dim tracking-[0.2em] uppercase font-medium">
Config Manager
</p>
</div>
<nav
className={`w-56 shrink-0 bg-gsd-surface-solid/95 border-r border-gsd-border overflow-y-auto z-40 backdrop-blur-sm ${className}`}
aria-label={isWeb ? "Settings sections" : "Navigation"}
>
{isWeb ? (
<div className="gsd-local-chrome px-4">
<p className="text-[10px] font-semibold tracking-[0.15em] uppercase text-gsd-text-muted">
Sections
</p>
</div>
) : (
<div className="gsd-local-chrome flex min-h-[4.5rem] flex-col items-start justify-center gap-1.5 px-4">
<BrandMark size="md" subtitle="Pi Config" />
<p className="text-[9px] text-gsd-text-muted tracking-wide pl-[2.75rem]">
Git · Ship · Done
</p>
</div>
)}
<div className="px-2 py-3">
{SECTION_GROUPS.map((group) => (
{sectionGroups.map((group) => (
<div key={group.label} className="mb-4">
<div className="px-3 py-1 text-[10px] font-semibold tracking-[0.15em] uppercase text-gsd-text-muted">
{group.label}
@@ -104,11 +149,10 @@ export function Sidebar({ active, onSelect, dirtySections }: SidebarProps) {
return (
<li key={s.id}>
<button
type="button"
onClick={() => onSelect(s.id)}
className={`w-full text-left px-3 py-1.5 rounded-md text-sm transition-all flex items-center justify-between ${
active === s.id
? "bg-gsd-accent-dim text-gsd-accent font-medium"
: "text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover"
className={`gsd-nav-item ${
active === s.id ? "gsd-nav-item-active" : "gsd-nav-item-idle"
}`}
>
<span>{s.label}</span>
@@ -126,6 +170,16 @@ export function Sidebar({ active, onSelect, dirtySections }: SidebarProps) {
</ul>
</div>
))}
{footerLink && (
<div className="px-3 pt-2 mt-2 border-t border-gsd-border">
<a
href={footerLink.href}
className="text-xs text-gsd-accent hover:text-gsd-accent-hover"
>
{footerLink.label}
</a>
</div>
)}
</div>
</nav>
);
+240
View File
@@ -0,0 +1,240 @@
// GSD Pi Config - Submit preset to gallery (GitHub PR)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useEffect, useState } from "react";
import { cleanPrefs } from "../lib/cleanPrefs";
import {
buildShareablePreset,
scanForLeakedSecrets,
serializePreferences,
} from "../lib/preferencesCore";
import { PRESETS_CONTRIBUTING_URL } from "../lib/presetsCatalog";
import { readWebDraftMeta } from "../platform/web";
import type { GSDPreferences } from "../types";
import { btn, btnPrimary, heading, modalPanel, prose } from "../lib/uiClasses";
const GITHUB_CLIENT_ID = import.meta.env.VITE_GITHUB_CLIENT_ID ?? "";
const SUBMIT_API_URL = import.meta.env.VITE_SUBMIT_PRESET_API_URL ?? "/api/submit-preset";
interface SubmitPresetModalProps {
open: boolean;
prefs: GSDPreferences;
onClose: () => void;
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
}
export function SubmitPresetModal({ open, prefs, onClose }: SubmitPresetModalProps) {
const meta = readWebDraftMeta();
const [slug, setSlug] = useState(meta.sourcePresetSlug ?? slugify(meta.title ?? "my-preset"));
const [title, setTitle] = useState(meta.title ?? "");
const [description, setDescription] = useState(meta.description ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [prUrl, setPrUrl] = useState("");
useEffect(() => {
if (!open) {
setError("");
setPrUrl("");
setBusy(false);
return;
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const startOAuth = () => {
if (!GITHUB_CLIENT_ID) {
setError("GitHub OAuth is not configured. Use the manual PR link below.");
return;
}
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>) as GSDPreferences;
const markdown = serializePreferences(cleaned);
const leaks = scanForLeakedSecrets(markdown);
if (leaks.length > 0) {
setError(`Remove secrets before submitting: ${leaks.join("; ")}`);
return;
}
const redirect = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/oauth/callback`;
const state = crypto.randomUUID();
sessionStorage.setItem("gsd-oauth-state", state);
sessionStorage.setItem(
"gsd-oauth-pending-submit",
JSON.stringify({
slug: slugify(slug),
title: title.trim() || slugify(slug),
description: description.trim(),
presetMarkdown: markdown,
}),
);
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: redirect,
scope: "public_repo",
state,
});
window.location.href = `https://github.com/login/oauth/authorize?${params}`;
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className={`w-full max-w-lg p-5 ${modalPanel}`}
onClick={(e) => e.stopPropagation()}
>
<h2 className={`${heading} text-sm font-semibold text-gsd-text`}>Submit to gallery</h2>
<p className={`${prose} text-xs text-gsd-text-dim mt-1`}>
Opens a pull request on{" "}
<code className="text-[10px]">open-gsd/gsd-pi-presets</code>. Content is redacted
for review see preview in Share.
</p>
{prUrl ? (
<div className="mt-4">
<p className="text-sm text-gsd-text">Pull request created:</p>
<a
href={prUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-gsd-accent break-all"
>
{prUrl}
</a>
<button
type="button"
onClick={onClose}
className={`mt-4 ${btnPrimary}`}
>
Done
</button>
</div>
) : (
<>
<div className="mt-4 space-y-3">
<label className="block text-xs text-gsd-text-dim">
Slug (filename)
<input
className="mt-1 w-full"
value={slug}
onChange={(e) => setSlug(e.target.value)}
/>
</label>
<label className="block text-xs text-gsd-text-dim">
Title
<input
className="mt-1 w-full"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label className="block text-xs text-gsd-text-dim">
Description
<textarea
className="mt-1 w-full"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
</div>
{error && (
<p className="mt-3 text-xs text-gsd-danger">{error}</p>
)}
<div className="flex flex-wrap gap-2 mt-4">
<button
type="button"
disabled={busy}
onClick={() => startOAuth()}
className={btnPrimary}
>
{busy ? "Submitting…" : "Sign in with GitHub"}
</button>
<button type="button" onClick={onClose} className={btn}>
Cancel
</button>
</div>
<p className="mt-4 text-[10px] text-gsd-text-muted">
Or{" "}
<a
href={PRESETS_CONTRIBUTING_URL}
target="_blank"
rel="noreferrer"
className="text-gsd-accent"
>
open a manual PR
</a>
. Preview redacted YAML:{" "}
<button
type="button"
className="text-gsd-accent underline"
onClick={() => {
void navigator.clipboard.writeText(
buildShareablePreset(
cleanPrefs(prefs as unknown as Record<string, unknown>) as GSDPreferences,
),
);
}}
>
copy share block
</button>
</p>
</>
)}
</div>
</div>
);
}
interface PendingSubmit {
slug: string;
title: string;
description: string;
presetMarkdown: string;
}
/** Call from OAuth callback route with code from GitHub redirect. */
export async function completeOAuthSubmit(code: string): Promise<string> {
const pendingRaw = sessionStorage.getItem("gsd-oauth-pending-submit");
const state = sessionStorage.getItem("gsd-oauth-state");
const urlState = new URLSearchParams(window.location.search).get("state");
if (!pendingRaw || !state || state !== urlState) {
throw new Error("OAuth state mismatch");
}
const pending = JSON.parse(pendingRaw) as PendingSubmit;
const redirectUri = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/oauth/callback`;
const res = await fetch(SUBMIT_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
redirectUri,
...pending,
}),
});
const data = (await res.json()) as { prUrl?: string; error?: string };
sessionStorage.removeItem("gsd-oauth-pending-submit");
sessionStorage.removeItem("gsd-oauth-state");
if (!res.ok || !data.prUrl) {
throw new Error(data.error ?? `Submit failed (${res.status})`);
}
sessionStorage.setItem("gsd-oauth-pr-url", data.prUrl);
return data.prUrl;
}
+4 -9
View File
@@ -1,7 +1,8 @@
// GSD2 Config - Theme Toggle (system / dark / light segmented control)
// GSD Pi Config - Theme Toggle (system / dark / light segmented control)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useTheme, type ThemePreference } from "../lib/theme";
import { btnSegment, btnSegmentActive, segmentGroup } from "../lib/uiClasses";
const OPTIONS: { value: ThemePreference; label: string; title: string }[] = [
{ value: "system", label: "Auto", title: "Follow system theme" },
@@ -13,9 +14,7 @@ export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<div
className="flex rounded-md border border-gsd-border overflow-hidden shrink-0"
role="radiogroup"
<div className={segmentGroup} role="radiogroup"
aria-label="Theme"
>
{OPTIONS.map((opt) => {
@@ -28,11 +27,7 @@ export function ThemeToggle() {
aria-checked={active}
onClick={() => setTheme(opt.value)}
title={opt.title}
className={`px-2.5 py-1 text-xs font-medium transition-colors ${
active
? "bg-gsd-accent text-gsd-on-accent"
: "bg-gsd-bg text-gsd-text-dim hover:text-gsd-text"
}`}
className={`text-xs font-medium ${active ? btnSegmentActive : btnSegment}`}
>
{opt.label}
</button>
+104
View File
@@ -0,0 +1,104 @@
// GSD Pi Config - Cloud web chrome (opengsd.net parity)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { CSSProperties, ReactNode } from "react";
import { NavLink } from "react-router-dom";
import { BrandMark } from "./BrandMark";
import { ThemeToggle } from "./ThemeToggle";
import { btn, btnSegment, btnSegmentActive, segmentGroup } from "../lib/uiClasses";
export type WebShellNav = "editor" | "gallery" | "new";
interface WebShellProps {
active: WebShellNav;
children: ReactNode;
/** Shown under nav on editor when a workspace is loaded */
workspaceLabel?: string;
}
const NAV: { id: WebShellNav; to: string; label: string }[] = [
{ id: "editor", to: "/", label: "Editor" },
{ id: "gallery", to: "/gallery", label: "Gallery" },
{ id: "new", to: "/new", label: "New preset" },
];
export function WebShell({ active, children, workspaceLabel }: WebShellProps) {
const shellStyle = {
"--gsd-shell-nav-height": "3.5rem",
"--gsd-shell-editor-strip": "2.25rem",
"--gsd-shell-offset":
active === "editor"
? "calc(var(--gsd-shell-nav-height) + var(--gsd-shell-editor-strip))"
: "var(--gsd-shell-nav-height)",
} as CSSProperties;
return (
<div
className="min-h-screen flex flex-col bg-gsd-bg text-gsd-text gsd-web-shell"
style={shellStyle}
>
<header className="shrink-0 border-b border-gsd-border bg-gsd-bg/90 backdrop-blur-md z-50">
<div className="flex h-[var(--gsd-shell-nav-height)] w-full items-center gap-3 px-4 sm:px-6">
<a
href="https://www.opengsd.net"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-md transition-opacity hover:opacity-90"
>
<BrandMark size="sm" subtitle="Pi Config" />
</a>
<nav className={`${segmentGroup} ml-1`} aria-label="Main">
{NAV.map((item) => (
<NavLink
key={item.id}
to={item.to}
end={item.id === "editor"}
className={({ isActive }) =>
`text-xs font-medium ${isActive ? btnSegmentActive : btnSegment}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-2 shrink-0">
<ThemeToggle />
<a
href="https://www.opengsd.net"
target="_blank"
rel="noopener noreferrer"
className={`${btn} hidden sm:inline-flex`}
>
opengsd.net
</a>
</div>
</div>
{active === "editor" && (
<div className="border-t border-gsd-border/80 bg-gsd-surface-solid/60">
<div className="flex h-[var(--gsd-shell-editor-strip)] w-full items-center gap-x-4 px-4 text-[11px] text-gsd-text-dim sm:px-6">
<span>
<span className="text-gsd-text-secondary font-medium">Cloud editor</span>
{" · "}
Import or create a config, edit in the browser, then download files for{" "}
<code className="font-mono text-[10px] text-gsd-text-muted">~/.gsd/</code>
</span>
{workspaceLabel && (
<span
className="font-mono text-[10px] text-gsd-accent truncate max-w-full"
title={workspaceLabel}
>
{workspaceLabel}
</span>
)}
</div>
</div>
)}
</header>
<div className="relative flex-1 flex flex-col min-h-0 z-[1]">{children}</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
// GSD Pi Config - Cloud editor empty / start state
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { Link } from "react-router-dom";
import { btn, btnPrimary, heading, prose } from "../lib/uiClasses";
interface WebStartPanelProps {
onUpload: () => void;
onLoadPreset: () => void;
}
const STEPS = [
{
n: "1",
title: "Bring your config in",
body: "Import preferences.md plus optional models.json and settings.json from your machine.",
},
{
n: "2",
title: "Edit in the browser",
body: "Tune models, hooks, git, and workflow settings. A draft stays in this session only.",
},
{
n: "3",
title: "Download to install",
body: "Use Download files and copy the three files into ~/.gsd/ for GSD Pi on your computer.",
},
] as const;
export function WebStartPanel({ onUpload, onLoadPreset }: WebStartPanelProps) {
const galleryHref = `${import.meta.env.BASE_URL}gallery`.replace(/\/?$/, "/gallery");
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-12">
<div className="w-full max-w-lg">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-gsd-accent mb-3">
Git · Ship · Done
</p>
<h1 className={`${heading} text-2xl font-semibold text-gsd-text mb-2`}>
Configure GSD Pi in the cloud
</h1>
<p className={`${prose} text-sm text-gsd-text-dim mb-8 max-w-md`}>
This editor cannot read or write files on your computer. Start from an import, a community
preset, or a blank template, then download when you are ready.
</p>
<div className="mb-8 space-y-4 border-l border-gsd-border pl-4">
{STEPS.map((step) => (
<div key={step.n} className="relative">
<span className="absolute -left-4 top-0.5 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-gsd-border-strong bg-gsd-surface-solid text-[10px] font-semibold text-gsd-accent">
{step.n}
</span>
<p className="text-sm font-medium text-gsd-text">{step.title}</p>
<p className="text-xs text-gsd-text-dim mt-0.5 leading-relaxed">{step.body}</p>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={onUpload} className={btnPrimary}>
Import files
</button>
<button type="button" onClick={onLoadPreset} className={btn}>
Load preset
</button>
<Link to="/new" className={btn}>
New preset
</Link>
</div>
<p className="mt-6 text-xs text-gsd-text-muted">
Or{" "}
<a href={galleryHref} className="text-gsd-accent hover:text-gsd-accent-hover">
browse the preset gallery
</a>
</p>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,23 @@
// GSD2 Config - Agents Library Section
// GSD Pi Config - Agents Library Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Mirrors the Skills Library but targets `.claude/agents/*.md` (flat .md files
// rather than skill directories). Global and project scopes supported.
import { useState, useEffect, useCallback, useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import { SectionHeader } from "../FormControls";
import { useConfigBackend } from "../../platform/backend";
import {
btn,
btnPrimary,
btnSegment,
btnSegmentActive,
btnDanger,
choiceBtn,
choiceBtnActive,
segmentGroup,
bannerDanger,
} from "../../lib/uiClasses";
export interface AgentInfo {
id: string;
@@ -24,6 +35,7 @@ interface Props {
type SaveState = "idle" | "saving" | "saved" | "error";
export function AgentsLibrarySection({ projectPath }: Props) {
const backend = useConfigBackend();
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [content, setContent] = useState<string>("");
@@ -39,8 +51,7 @@ export function AgentsLibrarySection({ projectPath }: Props) {
const loadAgents = useCallback(async () => {
try {
setError("");
const args = projectPath ? { projectPath } : {};
const list = await invoke<AgentInfo[]>("list_agents", args);
const list = await backend.listAgents(projectPath);
setAgents(list);
} catch (e) {
setError(String(e));
@@ -62,7 +73,7 @@ export function AgentsLibrarySection({ projectPath }: Props) {
}
(async () => {
try {
const text = await invoke<string>("read_agent", { path: selected.path });
const text = await backend.readAgent(selected.path);
setContent(text);
setOriginalContent(text);
setSaveState("idle");
@@ -93,7 +104,7 @@ export function AgentsLibrarySection({ projectPath }: Props) {
if (!selected) return;
setSaveState("saving");
try {
await invoke("write_agent", { path: selected.path, content });
await backend.writeAgent(selected.path, content);
setOriginalContent(content);
setSaveState("saved");
setTimeout(() => setSaveState("idle"), 1500);
@@ -110,7 +121,7 @@ export function AgentsLibrarySection({ projectPath }: Props) {
if (!selected) return;
if (!confirm(`Delete agent "${selected.name}"? This removes the .md file.`)) return;
try {
await invoke("delete_agent", { path: selected.path });
await backend.deleteAgent(selected.path);
setSelectedId(null);
await loadAgents();
} catch (e) {
@@ -126,7 +137,11 @@ export function AgentsLibrarySection({ projectPath }: Props) {
scope: newScope,
};
if (newScope === "project" && projectPath) args.projectPath = projectPath;
const created = await invoke<AgentInfo>("create_agent", args);
const created = await backend.createAgent(
args.name,
args.scope,
args.projectPath,
);
setShowNewDialog(false);
setNewName("");
await loadAgents();
@@ -143,20 +158,19 @@ export function AgentsLibrarySection({ projectPath }: Props) {
<div className="flex items-start justify-between mb-4">
<SectionHeader
title="Agents Library"
description="View and edit Claude Code subagents. Agents are single .md files with YAML frontmatter that Claude dispatches to for specialized tasks."
description="View and edit subagent definitions. Agents are single .md files with YAML frontmatter the runtime dispatches for specialized tasks."
/>
<button
onClick={() => setShowNewDialog(true)}
className="px-3 py-1.5 text-xs rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover transition-colors shrink-0"
>
<button type="button" onClick={() => setShowNewDialog(true)} className={`${btnPrimary} shrink-0`}>
+ New Agent
</button>
</div>
{error && (
<div className="mb-3 px-3 py-2 bg-gsd-danger/10 border border-gsd-danger/30 text-gsd-danger text-xs rounded flex items-center justify-between">
<div className={`${bannerDanger} mb-3 flex items-center justify-between text-xs`}>
<span>{error}</span>
<button onClick={() => setError("")} className="ml-2 hover:text-red-300">dismiss</button>
<button type="button" onClick={() => setError("")} className={`${btn} ml-2`}>
Dismiss
</button>
</div>
)}
@@ -177,25 +191,19 @@ export function AgentsLibrarySection({ projectPath }: Props) {
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Scope</label>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setNewScope("global")}
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
newScope === "global"
? "border-gsd-accent text-gsd-accent bg-gsd-accent-dim"
: "border-gsd-border text-gsd-text-dim hover:text-gsd-text"
}`}
className={newScope === "global" ? choiceBtnActive : choiceBtn}
>
Global (~/.claude/agents)
</button>
<button
type="button"
onClick={() => setNewScope("project")}
disabled={!hasProject}
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
newScope === "project"
? "border-gsd-accent text-gsd-accent bg-gsd-accent-dim"
: "border-gsd-border text-gsd-text-dim hover:text-gsd-text"
} ${!hasProject ? "opacity-40 cursor-not-allowed" : ""}`}
className={newScope === "project" ? choiceBtnActive : choiceBtn}
>
Project (.claude/agents)
</button>
@@ -206,16 +214,16 @@ export function AgentsLibrarySection({ projectPath }: Props) {
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => { setShowNewDialog(false); setNewName(""); }}
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text"
type="button"
onClick={() => {
setShowNewDialog(false);
setNewName("");
}}
className={btn}
>
Cancel
</button>
<button
onClick={createNew}
disabled={!newName.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover disabled:opacity-40 disabled:cursor-not-allowed"
>
<button type="button" onClick={createNew} disabled={!newName.trim()} className={btnPrimary}>
Create
</button>
</div>
@@ -234,15 +242,14 @@ export function AgentsLibrarySection({ projectPath }: Props) {
placeholder="Search agents..."
className="w-full text-xs"
/>
<div className="flex rounded-md border border-gsd-border overflow-hidden text-[10px]">
<div className={`${segmentGroup} text-[10px]`}>
{(["all", "global", "project"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScopeFilter(s)}
className={`flex-1 px-2 py-1 uppercase tracking-wider font-medium transition-colors ${
scopeFilter === s
? "bg-gsd-accent text-gsd-on-accent"
: "text-gsd-text-dim hover:text-gsd-text"
className={`flex-1 uppercase tracking-wider font-medium ${
scopeFilter === s ? btnSegmentActive : btnSegment
}`}
>
{s}
@@ -303,28 +310,23 @@ export function AgentsLibrarySection({ projectPath }: Props) {
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={remove}
className="px-2 py-1 text-[10px] rounded border border-gsd-danger/40 text-gsd-danger hover:bg-gsd-danger/10"
>
<button type="button" onClick={remove} className={btnDanger}>
Delete
</button>
{isDirty && (
<button
onClick={discard}
className="px-2 py-1 text-[10px] rounded border border-gsd-border text-gsd-text-dim hover:text-gsd-text"
>
<button type="button" onClick={discard} className={btn}>
Discard
</button>
)}
<button
type="button"
onClick={save}
disabled={!isDirty || saveState === "saving"}
className={`px-3 py-1 text-[11px] rounded font-medium transition-colors ${
className={
isDirty
? "bg-gsd-accent text-gsd-on-accent hover:bg-gsd-accent-hover"
: "bg-gsd-border text-gsd-text-dim cursor-not-allowed"
}`}
? btnPrimary
: `${btn} !bg-gsd-border !text-gsd-text-dim !border-transparent`
}
>
{saveState === "saving" ? "Saving..." : saveState === "saved" ? "Saved" : "Save"}
</button>
+71 -58
View File
@@ -1,10 +1,10 @@
// GSD2 Config - API Keys Manager
// GSD Pi Config - API Keys Manager
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { SectionHeader } from "../FormControls";
import { useConfigBackend } from "../../platform/backend";
import { btn, btnPrimary, bannerDanger } from "../../lib/uiClasses";
interface KeyStatus {
name: string;
@@ -27,25 +27,6 @@ interface KeyGroup {
}
const KEY_GROUPS: KeyGroup[] = [
{
id: "anthropic",
label: "Anthropic",
description: "Direct Claude API access",
keys: [
{
name: "ANTHROPIC_API_KEY",
label: "Anthropic API Key",
description: "Direct Claude API access (Opus, Sonnet, Haiku)",
url: "https://console.anthropic.com/settings/keys",
},
{
name: "ANTHROPIC_AUTH_TOKEN",
label: "Anthropic Auth Token",
description: "Alternative auth token for Anthropic API",
url: "https://console.anthropic.com/",
},
],
},
{
id: "openai",
label: "OpenAI",
@@ -65,6 +46,25 @@ const KEY_GROUPS: KeyGroup[] = [
},
],
},
{
id: "anthropic",
label: "Anthropic",
description: "Anthropic API (Opus, Sonnet, Haiku)",
keys: [
{
name: "ANTHROPIC_API_KEY",
label: "Anthropic API Key",
description: "Direct Anthropic API access",
url: "https://console.anthropic.com/settings/keys",
},
{
name: "ANTHROPIC_AUTH_TOKEN",
label: "Anthropic Auth Token",
description: "Alternative auth token for Anthropic API",
url: "https://console.anthropic.com/",
},
],
},
{
id: "google",
label: "Google",
@@ -279,33 +279,15 @@ interface OAuthProvider {
}
const OAUTH_PROVIDERS: OAuthProvider[] = [
{
id: "claude-code",
label: "Claude Code CLI",
description: "Anthropic's official CLI. Uses Anthropic subscription auth — zero API cost.",
binary: "claude",
installCmd: "npm install -g @anthropic-ai/claude-code",
authCmd: "claude /login",
docsUrl: "https://docs.claude.com/en/docs/claude-code",
},
{
id: "gemini-cli",
label: "Gemini CLI",
description: "Google's Gemini CLI with subscription auth.",
description: "Google Gemini CLI with subscription auth.",
binary: "gemini",
installCmd: "npm install -g @google/gemini-cli",
authCmd: "gemini auth",
docsUrl: "https://github.com/google-gemini/gemini-cli",
},
{
id: "gcloud",
label: "Google Cloud (Vertex AI)",
description: "Use gcloud application-default login for Vertex AI (Claude & Gemini via GCP).",
binary: "gcloud",
installCmd: "brew install --cask google-cloud-sdk",
authCmd: "gcloud auth application-default login",
docsUrl: "https://cloud.google.com/sdk/docs/install",
},
{
id: "github",
label: "GitHub CLI (Copilot)",
@@ -315,12 +297,31 @@ const OAUTH_PROVIDERS: OAuthProvider[] = [
authCmd: "gh auth login",
docsUrl: "https://cli.github.com/",
},
{
id: "gcloud",
label: "Google Cloud (Vertex AI)",
description: "gcloud application-default login for Vertex AI (multi-vendor models via GCP).",
binary: "gcloud",
installCmd: "brew install --cask google-cloud-sdk",
authCmd: "gcloud auth application-default login",
docsUrl: "https://cloud.google.com/sdk/docs/install",
},
{
id: "claude-code",
label: "Claude Code CLI",
description: "Anthropic CLI with subscription auth (no per-token API billing when using subscription).",
binary: "claude",
installCmd: "npm install -g @anthropic-ai/claude-code",
authCmd: "claude /login",
docsUrl: "https://docs.claude.com/en/docs/claude-code",
},
];
// Flatten all key names for bulk status fetch
const ALL_KEY_NAMES = KEY_GROUPS.flatMap((g) => g.keys.map((k) => k.name));
export function ApiKeysSection() {
const backend = useConfigBackend();
const [statuses, setStatuses] = useState<Record<string, KeyStatus>>({});
const [editing, setEditing] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
@@ -332,7 +333,7 @@ export function ApiKeysSection() {
const refreshStatuses = useCallback(async () => {
try {
setError("");
const list = await invoke<KeyStatus[]>("list_key_statuses", { names: ALL_KEY_NAMES });
const list = await backend.listKeyStatuses(ALL_KEY_NAMES);
const map: Record<string, KeyStatus> = {};
for (const s of list) map[s.name] = s;
setStatuses(map);
@@ -342,23 +343,26 @@ export function ApiKeysSection() {
}, []);
const refreshCliStatus = useCallback(async () => {
if (!backend.canCheckCli()) {
setCliStatus({});
return;
}
const entries: Record<string, boolean> = {};
for (const p of OAUTH_PROVIDERS) {
try {
const ok = await invoke<boolean>("check_cli_installed", { binary: p.binary });
entries[p.id] = ok;
entries[p.id] = await backend.checkCliInstalled(p.binary);
} catch {
entries[p.id] = false;
}
}
setCliStatus(entries);
}, []);
}, [backend]);
useEffect(() => { refreshStatuses(); refreshCliStatus(); }, [refreshStatuses, refreshCliStatus]);
const startEdit = async (name: string) => {
try {
const v = await invoke<string | null>("get_key", { name });
const v = await backend.getKey(name);
setEditing((p) => ({ ...p, [name]: v ?? "" }));
} catch (e) {
setError(String(e));
@@ -369,9 +373,9 @@ export function ApiKeysSection() {
try {
const value = editing[name] ?? "";
if (value) {
await invoke("set_key", { name, value });
await backend.setKey(name, value);
} else {
await invoke("delete_key", { name });
await backend.deleteKey(name);
}
setEditing((p) => {
const next = { ...p };
@@ -395,7 +399,7 @@ export function ApiKeysSection() {
const clearKey = async (name: string) => {
if (!confirm(`Delete key ${name}?`)) return;
try {
await invoke("delete_key", { name });
await backend.deleteKey(name);
setEditing((p) => {
const next = { ...p };
delete next[name];
@@ -413,7 +417,7 @@ export function ApiKeysSection() {
const exportEnv = async () => {
try {
const path = await invoke<string>("export_env_file", { names: ALL_KEY_NAMES });
const path = await backend.exportEnvFile(ALL_KEY_NAMES);
setExportMsg(`Exported to ${path}`);
setTimeout(() => setExportMsg(""), 4000);
} catch (e) {
@@ -423,7 +427,7 @@ export function ApiKeysSection() {
const openExternal = async (url: string) => {
try {
await openUrl(url);
await backend.openUrl(url);
} catch (e) {
setError(String(e));
}
@@ -451,25 +455,28 @@ export function ApiKeysSection() {
<div className="flex items-start justify-between gap-4 mb-4">
<SectionHeader
title="API Keys & Auth"
description="Store provider keys securely in the macOS Keychain. Export to ~/.gsd/env.sh to source into your shell."
description={
backend.isWeb()
? "Store keys in this browser workspace (export env.sh to copy to your machine). CLI detection requires the desktop app."
: "Store provider keys securely in the macOS Keychain. Export to ~/.gsd/env.sh to source into your shell."
}
/>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-gsd-text-dim">
{setCount} / {ALL_KEY_NAMES.length} set
</span>
<button
onClick={exportEnv}
className="px-3 py-1.5 text-xs rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover transition-colors"
>
<button type="button" onClick={exportEnv} className={btnPrimary}>
Export env.sh
</button>
</div>
</div>
{error && (
<div className="mb-3 px-3 py-2 bg-gsd-danger/10 border border-gsd-danger/30 text-gsd-danger text-xs rounded flex items-center justify-between">
<div className={`${bannerDanger} mb-3 flex items-center justify-between text-xs`}>
<span>{error}</span>
<button onClick={() => setError("")} className="ml-2 hover:text-red-300">dismiss</button>
<button type="button" onClick={() => setError("")} className={`${btn} ml-2`}>
Dismiss
</button>
</div>
)}
{exportMsg && (
@@ -491,6 +498,12 @@ export function ApiKeysSection() {
<h3 className="text-[10px] font-semibold tracking-[0.15em] uppercase text-gsd-text-muted mb-2 px-1">
OAuth / CLI Auth (subscription-based)
</h3>
{!backend.canCheckCli() && (
<p className="text-[11px] text-gsd-text-dim mb-2 px-1">
Install and run these CLIs on your machine the web app cannot detect them. Use the
desktop app for live CLI status.
</p>
)}
<div className="grid grid-cols-2 gap-3">
{OAUTH_PROVIDERS.map((p) => {
const installed = cliStatus[p.id];
+10 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Budget & Cost Settings Section
// GSD Pi Config - Budget & Cost Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, BudgetEnforcementMode } from "../../types";
@@ -47,6 +47,15 @@ export function BudgetSection({ prefs, onChange }: Props) {
placeholder="0"
/>
</Field>
<Field path="per_unit_cost_cap_usd" value={prefs.per_unit_cost_cap_usd} label="Per-Unit Cost Cap ($)" description="Maximum USD per dispatched unit.">
<NumberField
value={prefs.per_unit_cost_cap_usd}
onChange={(v) => set("per_unit_cost_cap_usd", v)}
min={0}
placeholder="5"
/>
</Field>
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - CMux Settings Section
// GSD Pi Config - CMux Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, CmuxPreferences } from "../../types";
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Codebase Map Settings Section
// GSD Pi Config - Codebase Map Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, CodebaseMapPreferences } from "../../types";
+50 -5
View File
@@ -1,8 +1,8 @@
// GSD Setup - Context Management Settings Section
// GSD Pi Config - Context Management Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, ContextManagementConfig } from "../../types";
import { Field, Toggle, NumberField, SectionHeader } from "../FormControls";
import type { GSDPreferences, ContextManagementConfig, ContextModeConfig } from "../../types";
import { Field, Toggle, NumberField, TagInput, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
@@ -14,11 +14,18 @@ export function ContextSection({ prefs, onChange }: Props) {
const setCtx = (update: Partial<ContextManagementConfig>) =>
onChange({ ...prefs, context_management: { ...ctx, ...update } });
const mode = prefs.context_mode ?? {};
const setMode = (update: Partial<ContextModeConfig>) =>
onChange({ ...prefs, context_mode: { ...mode, ...update } });
const set = <K extends keyof GSDPreferences>(key: K, val: GSDPreferences[K]) =>
onChange({ ...prefs, [key]: val });
return (
<div>
<SectionHeader
title="Context Management"
description="Control how GSD manages context window usage: observation masking, compaction, and tool result limits."
title="Context"
description="Context window management, gsd_exec sandboxing, and compaction settings."
/>
<Field path="context_management.observation_masking" label="Observation Masking" description="Mask old tool results to reduce context bloat.">
@@ -36,6 +43,44 @@ export function ContextSection({ prefs, onChange }: Props) {
<Field path="context_management.tool_result_max_chars" value={ctx.tool_result_max_chars} label="Tool Result Max Chars" description="Max characters per tool result (200-10000).">
<NumberField value={ctx.tool_result_max_chars} onChange={(v) => setCtx({ tool_result_max_chars: v })} min={200} max={10000} placeholder="800" />
</Field>
<Field path="context_window_override" value={prefs.context_window_override} label="Context Window Override" description="Token limit for prompt budget when the model registry cannot resolve runtime window.">
<NumberField
value={prefs.context_window_override}
onChange={(v) => set("context_window_override", v)}
min={1000}
placeholder="Optional"
/>
</Field>
<h3 className="text-sm font-medium text-gsd-text-dim mt-6 mb-2 uppercase tracking-wider">Context Mode (gsd_exec)</h3>
<Field path="context_mode.enabled" label="Enabled" description="Sandbox tool output via subprocess digest. Default on unless explicitly false.">
<Toggle
checked={mode.enabled !== false}
onChange={(v) => setMode({ enabled: v ? undefined : false })}
/>
</Field>
<Field path="context_mode.exec_timeout_ms" value={mode.exec_timeout_ms} label="Exec Timeout (ms)">
<NumberField value={mode.exec_timeout_ms} onChange={(v) => setMode({ exec_timeout_ms: v })} min={1000} max={600000} placeholder="30000" />
</Field>
<Field path="context_mode.exec_stdout_cap_bytes" value={mode.exec_stdout_cap_bytes} label="Stdout Cap (bytes)">
<NumberField value={mode.exec_stdout_cap_bytes} onChange={(v) => setMode({ exec_stdout_cap_bytes: v })} min={4096} max={16777216} placeholder="1048576" />
</Field>
<Field path="context_mode.exec_digest_chars" value={mode.exec_digest_chars} label="Digest Chars">
<NumberField value={mode.exec_digest_chars} onChange={(v) => setMode({ exec_digest_chars: v })} min={0} max={4000} placeholder="300" />
</Field>
<Field path="context_mode.exec_env_allowlist" label="Env Allowlist" description="Extra env var names forwarded to sandboxed processes (PATH and HOME always forwarded).">
<TagInput
values={mode.exec_env_allowlist ?? []}
onChange={(v) => setMode({ exec_env_allowlist: v.length > 0 ? v : undefined })}
placeholder="VAR_NAME"
/>
</Field>
</div>
);
}
@@ -1,4 +1,4 @@
// GSD Setup - Custom Providers (models.json) Section
// GSD Pi Config - Custom Providers (models.json) Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// CRUD editor for `~/.gsd/agent/models.json` (or `<project>/.gsd/agent/models.json`).
@@ -9,6 +9,7 @@
import { useState } from "react";
import { SectionHeader, Field, TextField, NumberField, Toggle } from "../FormControls";
import { btn, btnPrimary, btnDanger } from "../../lib/uiClasses";
import { MODEL_CATALOG } from "../../constants";
import type {
GSDModelsConfig,
@@ -76,10 +77,7 @@ export function CustomProvidersSection({ value, onChange }: Props) {
<div className="text-xs text-gsd-text-dim">
{entries.length} provider{entries.length === 1 ? "" : "s"}
</div>
<button
onClick={addProvider}
className="px-3 py-1.5 text-xs rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover transition-colors"
>
<button type="button" onClick={addProvider} className={btnPrimary}>
+ Add provider
</button>
</div>
@@ -162,11 +160,7 @@ function ProviderCard({ id, cfg, collision, onRename, onChange, onDelete }: Card
</p>
)}
</div>
<button
onClick={onDelete}
title="Delete provider"
className="shrink-0 px-2 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-danger hover:border-gsd-danger transition-colors"
>
<button type="button" onClick={onDelete} title="Delete provider" className={`${btnDanger} shrink-0`}>
Delete
</button>
</div>
@@ -200,11 +194,7 @@ function ProviderCard({ id, cfg, collision, onRename, onChange, onDelete }: Card
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="px-2 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text"
>
<button type="button" onClick={() => setShowKey((s) => !s)} className={btn}>
{showKey ? "Hide" : "Show"}
</button>
</div>
@@ -215,10 +205,7 @@ function ProviderCard({ id, cfg, collision, onRename, onChange, onDelete }: Card
<h4 className="text-xs font-semibold tracking-wide text-gsd-text uppercase">
Models ({models.length})
</h4>
<button
onClick={addModel}
className="px-2 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover"
>
<button type="button" onClick={addModel} className={btn}>
+ Add model
</button>
</div>
@@ -240,10 +227,7 @@ function ProviderCard({ id, cfg, collision, onRename, onChange, onDelete }: Card
placeholder="model-id (required)"
className="flex-1 font-mono text-xs"
/>
<button
onClick={() => removeModel(idx)}
className="px-2 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-danger hover:border-gsd-danger"
>
<button type="button" onClick={() => removeModel(idx)} className={btnDanger}>
Remove
</button>
</div>
@@ -251,7 +235,7 @@ function ProviderCard({ id, cfg, collision, onRename, onChange, onDelete }: Card
<TextField
value={m.name}
onChange={(v) => updateModel(idx, { name: v })}
placeholder="Claude Sonnet 4 (OpenRouter)"
placeholder="Display name (e.g. Sonnet 4 via OpenRouter)"
className="w-72"
/>
</Field>
@@ -1,4 +1,4 @@
// GSD Setup - Discussion Settings Section
// GSD Pi Config - Discussion Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, DiscussDepth } from "../../types";
+22 -12
View File
@@ -1,16 +1,17 @@
// GSD Setup - Experimental Settings Section
// GSD Pi Config - Experimental Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, ReactiveExecutionConfig, GateEvaluationConfig } from "../../types";
import { Field, Toggle, NumberField, ModelPicker, TagInput, SectionHeader } from "../FormControls";
import { MODEL_CATALOG } from "../../constants";
import { Field, Toggle, NumberField, ModelPicker, SectionHeader, MultiSelectField, SelectField } from "../FormControls";
import { KNOWN_SLICE_GATES, REACTIVE_ISOLATION_MODES, type ProviderCatalog } from "../../constants";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
modelCatalog?: readonly ProviderCatalog[];
}
export function ExperimentalSection({ prefs, onChange }: Props) {
export function ExperimentalSection({ prefs, onChange, modelCatalog = [] }: Props) {
const exp = prefs.experimental ?? {};
const reactive = prefs.reactive_execution ?? {};
const gate = prefs.gate_evaluation ?? {};
@@ -47,8 +48,17 @@ export function ExperimentalSection({ prefs, onChange }: Props) {
<NumberField value={reactive.max_parallel} onChange={(v) => setReactive({ max_parallel: v })} min={1} max={16} placeholder="2" />
</Field>
<Field path="reactive_execution.isolation_mode" value={reactive.isolation_mode} label="Isolation Mode" description="How reactive tasks share the working tree.">
<SelectField
value={reactive.isolation_mode}
onChange={(v) => setReactive({ isolation_mode: v })}
options={REACTIVE_ISOLATION_MODES}
placeholder="Default"
/>
</Field>
<Field path="reactive_execution.subagent_model" label="Subagent Model" description="Optional model override for reactive subagents.">
<ModelPicker value={reactive.subagent_model} onChange={(v) => setReactive({ subagent_model: v })} catalog={MODEL_CATALOG} placeholder="Default" />
<ModelPicker value={reactive.subagent_model} onChange={(v) => setReactive({ subagent_model: v })} catalog={modelCatalog} placeholder="Default" />
</Field>
<h3 className="text-sm font-medium text-gsd-text-dim mt-6 mb-2 uppercase tracking-wider">Gate Evaluation</h3>
@@ -58,10 +68,10 @@ export function ExperimentalSection({ prefs, onChange }: Props) {
</Field>
<Field path="gate_evaluation.slice_gates" label="Slice Gates" description="Which slice-scoped gates to evaluate in parallel.">
<TagInput
<MultiSelectField
values={gate.slice_gates ?? []}
onChange={(v) => setGate({ slice_gates: v.length > 0 ? v : undefined })}
placeholder="e.g. Q3, Q4"
options={KNOWN_SLICE_GATES.map((g) => ({ value: g, label: g }))}
/>
</Field>
@@ -71,16 +81,16 @@ export function ExperimentalSection({ prefs, onChange }: Props) {
<h3 className="text-sm font-medium text-gsd-text-dim mt-6 mb-2 uppercase tracking-wider">Auto Supervisor</h3>
<Field label="Model" description="Model ID for the auto-mode supervisor.">
<Field path="auto_supervisor.model" label="Model" description="Model ID for the auto-mode supervisor.">
<ModelPicker
value={prefs.auto_supervisor?.model}
onChange={(v) => onChange({ ...prefs, auto_supervisor: { ...prefs.auto_supervisor, model: v } })}
catalog={MODEL_CATALOG}
catalog={modelCatalog}
placeholder="Current model"
/>
</Field>
<Field label="Soft Timeout (min)" description="Minutes before soft warning.">
<Field path="auto_supervisor.soft_timeout_minutes" value={prefs.auto_supervisor?.soft_timeout_minutes} label="Soft Timeout (min)" description="Minutes before soft warning.">
<NumberField
value={prefs.auto_supervisor?.soft_timeout_minutes}
onChange={(v) => onChange({ ...prefs, auto_supervisor: { ...prefs.auto_supervisor, soft_timeout_minutes: v } })}
@@ -89,7 +99,7 @@ export function ExperimentalSection({ prefs, onChange }: Props) {
/>
</Field>
<Field label="Idle Timeout (min)" description="Minutes of inactivity before intervention.">
<Field path="auto_supervisor.idle_timeout_minutes" value={prefs.auto_supervisor?.idle_timeout_minutes} label="Idle Timeout (min)" description="Minutes of inactivity before intervention.">
<NumberField
value={prefs.auto_supervisor?.idle_timeout_minutes}
onChange={(v) => onChange({ ...prefs, auto_supervisor: { ...prefs.auto_supervisor, idle_timeout_minutes: v } })}
@@ -98,7 +108,7 @@ export function ExperimentalSection({ prefs, onChange }: Props) {
/>
</Field>
<Field label="Hard Timeout (min)" description="Minutes before forced termination.">
<Field path="auto_supervisor.hard_timeout_minutes" value={prefs.auto_supervisor?.hard_timeout_minutes} label="Hard Timeout (min)" description="Minutes before forced termination.">
<NumberField
value={prefs.auto_supervisor?.hard_timeout_minutes}
onChange={(v) => onChange({ ...prefs, auto_supervisor: { ...prefs.auto_supervisor, hard_timeout_minutes: v } })}
+46 -11
View File
@@ -1,8 +1,17 @@
// GSD Setup - General Settings Section
// GSD Pi Config - General Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, WorkflowMode, TokenProfile, SearchProvider, WidgetMode, ContextSelectionMode, ServiceTier } from "../../types";
import { Field, Toggle, SelectField, NumberField, SectionHeader } from "../FormControls";
import type {
GSDPreferences,
WorkflowMode,
TokenProfile,
SearchProvider,
WidgetMode,
ContextSelectionMode,
ServiceTier,
PlanningDepth,
} from "../../types";
import { Field, Toggle, SelectField, NumberField, TextField, SectionHeader } from "../FormControls";
import {
applyModePreset,
applyProfilePreset,
@@ -86,7 +95,7 @@ export function GeneralSection({ prefs, onChange }: Props) {
description="Core workflow mode, profiles, and global behavior."
/>
<Field path="mode" value={prefs.mode} label="Workflow Mode" description="Solo (single dev) or Team (multi-dev). Picking a mode cascades sensible defaults for git, parallel, phases, and notifications.">
<Field path="mode" value={prefs.mode} label="Workflow Mode">
<SelectField<WorkflowMode>
value={prefs.mode}
onChange={onModeChange}
@@ -95,16 +104,33 @@ export function GeneralSection({ prefs, onChange }: Props) {
/>
</Field>
<Field path="token_profile" value={prefs.token_profile} label="Token Profile" description="Picking a profile cascades phase skipping, context compression, and verification defaults. Model IDs are left alone.">
<Field path="token_profile" value={prefs.token_profile} label="Token Profile">
<SelectField<TokenProfile>
value={prefs.token_profile}
onChange={onProfileChange}
options={["budget", "balanced", "quality"]}
options={["budget", "balanced", "quality", "burn-max"]}
placeholder="Not set"
/>
</Field>
<Field path="search_provider" value={prefs.search_provider} label="Search Provider" description="Search backend. 'auto' uses the default behavior.">
<Field path="planning_depth" value={prefs.planning_depth} label="Planning Depth" description="New project/milestone interactive planning flow depth.">
<SelectField<PlanningDepth>
value={prefs.planning_depth}
onChange={(v) => set("planning_depth", v)}
options={["light", "deep"]}
placeholder="light"
/>
</Field>
<Field path="language" value={prefs.language} label="Response Language" description="Language for agent responses (e.g. English).">
<TextField
value={prefs.language}
onChange={(v) => set("language", v || undefined)}
placeholder="Default"
/>
</Field>
<Field path="search_provider" value={prefs.search_provider} label="Search Provider">
<SelectField<SearchProvider>
value={prefs.search_provider}
onChange={(v) => set("search_provider", v)}
@@ -112,7 +138,7 @@ export function GeneralSection({ prefs, onChange }: Props) {
/>
</Field>
<Field path="widget_mode" value={prefs.widget_mode} label="Widget Mode" description="Default widget display for auto-mode dashboard.">
<Field path="widget_mode" value={prefs.widget_mode} label="Widget Mode">
<SelectField<WidgetMode>
value={prefs.widget_mode}
onChange={(v) => set("widget_mode", v)}
@@ -120,7 +146,7 @@ export function GeneralSection({ prefs, onChange }: Props) {
/>
</Field>
<Field path="context_selection" value={prefs.context_selection} label="Context Selection" description="File inlining strategy. 'full' inlines entire files, 'smart' uses semantic chunking.">
<Field path="context_selection" value={prefs.context_selection} label="Context Selection">
<SelectField<ContextSelectionMode>
value={prefs.context_selection}
onChange={(v) => set("context_selection", v)}
@@ -129,7 +155,7 @@ export function GeneralSection({ prefs, onChange }: Props) {
/>
</Field>
<Field path="service_tier" value={prefs.service_tier} label="Service Tier" description="OpenAI tier. 'priority' = 2x cost/faster, 'flex' = 0.5x cost/slower. Only for gpt-5.4 models.">
<Field path="service_tier" value={prefs.service_tier} label="Service Tier">
<SelectField<ServiceTier>
value={prefs.service_tier}
onChange={(v) => set("service_tier", v)}
@@ -138,7 +164,7 @@ export function GeneralSection({ prefs, onChange }: Props) {
/>
</Field>
<Field path="unique_milestone_ids" label="Unique Milestone IDs" description="Generate milestone IDs in M{seq}-{rand6} format (recommended for teams).">
<Field path="unique_milestone_ids" label="Unique Milestone IDs">
<Toggle
checked={prefs.unique_milestone_ids ?? false}
onChange={(v) => set("unique_milestone_ids", v)}
@@ -188,6 +214,15 @@ export function GeneralSection({ prefs, onChange }: Props) {
placeholder="30"
/>
</Field>
<Field path="min_request_interval_ms" value={prefs.min_request_interval_ms} label="Min Request Interval (ms)" description="Minimum ms between auto-mode LLM requests. 0 disables rate pacing.">
<NumberField
value={prefs.min_request_interval_ms}
onChange={(v) => set("min_request_interval_ms", v)}
min={0}
placeholder="0"
/>
</Field>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
// GSD Pi Config - GitHub Sync Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, GitHubSyncConfig } from "../../types";
import { Field, Toggle, TextField, NumberField, TagInput, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
}
export function GitHubSection({ prefs, onChange }: Props) {
const github = prefs.github ?? {};
const setGithub = (patch: Partial<GitHubSyncConfig>) =>
onChange({ ...prefs, github: { ...github, ...patch } });
return (
<div>
<SectionHeader
title="GitHub Sync"
description="Sync GSD milestones, slices, and tasks to GitHub Issues, Projects, and PRs."
/>
<Field path="github.enabled" label="Enabled">
<Toggle checked={github.enabled ?? false} onChange={(v) => setGithub({ enabled: v })} />
</Field>
<Field path="github.repo" label="Repository" description="owner/repo — auto-detected from git remote if omitted.">
<TextField
value={github.repo}
onChange={(v) => setGithub({ repo: v })}
placeholder="open-gsd/gsd-pi"
/>
</Field>
<Field path="github.project" value={github.project} label="Project Number" description="GitHub Projects v2 number (optional).">
<NumberField
value={github.project}
onChange={(v) => setGithub({ project: v })}
min={1}
placeholder="Optional"
/>
</Field>
<Field path="github.labels" label="Issue Labels">
<TagInput
values={github.labels ?? []}
onChange={(v) => setGithub({ labels: v.length > 0 ? v : undefined })}
placeholder="Add label"
/>
</Field>
<Field path="github.auto_link_commits" label="Auto-Link Commits" description='Append "Resolves #N" to task commits.'>
<Toggle checked={github.auto_link_commits ?? true} onChange={(v) => setGithub({ auto_link_commits: v })} />
</Field>
<Field path="github.slice_prs" label="Slice PRs" description="Create per-slice draft PRs.">
<Toggle checked={github.slice_prs ?? true} onChange={(v) => setGithub({ slice_prs: v })} />
</Field>
</div>
);
}
+46 -15
View File
@@ -1,8 +1,14 @@
// GSD Setup - Git Settings Section
// GSD Pi Config - Git Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, GitPreferences, GitIsolation, GitMergeStrategy } from "../../types";
import { Field, Toggle, SelectField, TextField, SectionHeader } from "../FormControls";
import type {
GSDPreferences,
GitPreferences,
GitIsolation,
GitMergeStrategy,
GitCollapseCadence,
} from "../../types";
import { Field, Toggle, SelectField, TextField, SectionHeader, LabeledSelectField } from "../FormControls";
import { COMMIT_TYPES } from "../../constants";
interface Props {
@@ -57,19 +63,27 @@ export function GitSection({ prefs, onChange }: Props) {
</Field>
<Field path="git.pre_merge_check" label="Pre-Merge Check" description="Run pre-merge checks before merging worktree.">
<select
value={git.pre_merge_check === undefined ? "" : String(git.pre_merge_check)}
onChange={(e) => {
const v = e.target.value;
setGit({ pre_merge_check: v === "" ? undefined : v === "auto" ? "auto" : v === "true" });
<LabeledSelectField
value={
git.pre_merge_check === undefined
? undefined
: String(git.pre_merge_check)
}
onChange={(v) => {
if (!v) {
setGit({ pre_merge_check: undefined });
return;
}
setGit({
pre_merge_check: v === "auto" ? "auto" : v === "true",
});
}}
className="w-52"
>
<option value="">Default</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
<option value="auto">Auto</option>
</select>
options={[
{ value: "true", label: "Enabled" },
{ value: "false", label: "Disabled" },
{ value: "auto", label: "Auto" },
]}
/>
</Field>
<Field path="git.main_branch" label="Main Branch" description="Primary branch name for new git repos.">
@@ -105,6 +119,23 @@ export function GitSection({ prefs, onChange }: Props) {
className="w-52"
/>
</Field>
<Field path="git.absorb_snapshot_commits" label="Absorb Snapshot Commits" description="Squash gsd snapshot commits into the next real commit.">
<Toggle checked={git.absorb_snapshot_commits ?? true} onChange={(v) => setGit({ absorb_snapshot_commits: v })} />
</Field>
<Field path="git.collapse_cadence" value={git.collapse_cadence} label="Collapse Cadence" description="When worktree commits collapse back to main.">
<SelectField<GitCollapseCadence>
value={git.collapse_cadence}
onChange={(v) => setGit({ collapse_cadence: v })}
options={["milestone", "slice"]}
placeholder="milestone"
/>
</Field>
<Field path="git.milestone_resquash" label="Milestone Resquash" description="With slice cadence, squash slice commits to one milestone commit at end.">
<Toggle checked={git.milestone_resquash ?? true} onChange={(v) => setGit({ milestone_resquash: v })} />
</Field>
</div>
);
}
+59 -31
View File
@@ -1,23 +1,27 @@
// GSD Setup - Hooks Settings Section
// GSD Pi Config - Hooks Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, PostUnitHookConfig, PreDispatchHookConfig } from "../../types";
import { KNOWN_UNIT_TYPES } from "../../types";
import { SectionHeader } from "../FormControls";
import { UNIT_TYPE_OPTIONS } from "../../types";
import type { ProviderCatalog } from "../../constants";
import { ModelPicker, MultiSelectField, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
modelCatalog?: readonly ProviderCatalog[];
}
function PostHookCard({
hook,
onUpdate,
onRemove,
modelCatalog,
}: {
hook: PostUnitHookConfig;
onUpdate: (h: PostUnitHookConfig) => void;
onRemove: () => void;
modelCatalog: readonly ProviderCatalog[];
}) {
return (
<div className="p-3 rounded-lg bg-gsd-surface border border-gsd-border mb-3">
@@ -44,19 +48,13 @@ function PostHookCard({
<div className="space-y-2">
<div>
<label className="text-xs text-gsd-text-dim block mb-1">After (unit types)</label>
<select
multiple
value={hook.after}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, (o) => o.value);
onUpdate({ ...hook, after: selected });
}}
className="w-full h-20 text-xs"
>
{KNOWN_UNIT_TYPES.map((ut) => (
<option key={ut} value={ut}>{ut}</option>
))}
</select>
<MultiSelectField
values={hook.after}
onChange={(after) => onUpdate({ ...hook, after })}
options={UNIT_TYPE_OPTIONS}
placeholder="Select unit types…"
className="w-full"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Prompt</label>
@@ -71,7 +69,13 @@ function PostHookCard({
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-gsd-text-dim block mb-1">Model</label>
<input type="text" value={hook.model ?? ""} onChange={(e) => onUpdate({ ...hook, model: e.target.value || undefined })} placeholder="Default" className="w-full text-xs" />
<ModelPicker
value={hook.model}
onChange={(v) => onUpdate({ ...hook, model: v })}
catalog={modelCatalog}
placeholder="Default"
className="w-full text-xs"
/>
</div>
<div className="w-20">
<label className="text-xs text-gsd-text-dim block mb-1">Max Cycles</label>
@@ -87,6 +91,10 @@ function PostHookCard({
<label className="text-xs text-gsd-text-dim block mb-1">Retry On</label>
<input type="text" value={hook.retry_on ?? ""} onChange={(e) => onUpdate({ ...hook, retry_on: e.target.value || undefined })} placeholder="Trigger file" className="w-full text-xs" />
</div>
<div className="flex-1">
<label className="text-xs text-gsd-text-dim block mb-1">Agent</label>
<input type="text" value={hook.agent ?? ""} onChange={(e) => onUpdate({ ...hook, agent: e.target.value || undefined })} placeholder="Optional agent id" className="w-full text-xs" />
</div>
</div>
</div>
</div>
@@ -97,10 +105,12 @@ function PreHookCard({
hook,
onUpdate,
onRemove,
modelCatalog,
}: {
hook: PreDispatchHookConfig;
onUpdate: (h: PreDispatchHookConfig) => void;
onRemove: () => void;
modelCatalog: readonly ProviderCatalog[];
}) {
return (
<div className="p-3 rounded-lg bg-gsd-surface border border-gsd-border mb-3">
@@ -136,19 +146,13 @@ function PreHookCard({
</div>
<div className="flex-1">
<label className="text-xs text-gsd-text-dim block mb-1">Before (unit types)</label>
<select
multiple
value={hook.before}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, (o) => o.value);
onUpdate({ ...hook, before: selected });
}}
className="w-full h-16 text-xs"
>
{KNOWN_UNIT_TYPES.map((ut) => (
<option key={ut} value={ut}>{ut}</option>
))}
</select>
<MultiSelectField
values={hook.before}
onChange={(before) => onUpdate({ ...hook, before })}
options={UNIT_TYPE_OPTIONS}
placeholder="Select unit types…"
className="w-full"
/>
</div>
</div>
{hook.action === "modify" && (
@@ -175,12 +179,34 @@ function PreHookCard({
<input type="text" value={hook.skip_if ?? ""} onChange={(e) => onUpdate({ ...hook, skip_if: e.target.value || undefined })} className="w-full text-xs" placeholder="Relative file path" />
</div>
)}
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-gsd-text-dim block mb-1">Model</label>
<ModelPicker
value={hook.model}
onChange={(v) => onUpdate({ ...hook, model: v })}
catalog={modelCatalog}
placeholder="Default"
className="w-full text-xs"
/>
</div>
<div className="flex-1">
<label className="text-xs text-gsd-text-dim block mb-1">Unit type filter</label>
<input
type="text"
value={hook.unit_type ?? ""}
onChange={(e) => onUpdate({ ...hook, unit_type: e.target.value || undefined })}
className="w-full text-xs"
placeholder="Optional unit type"
/>
</div>
</div>
</div>
</div>
);
}
export function HooksSection({ prefs, onChange }: Props) {
export function HooksSection({ prefs, onChange, modelCatalog = [] }: Props) {
const postHooks = prefs.post_unit_hooks ?? [];
const preHooks = prefs.pre_dispatch_hooks ?? [];
@@ -216,6 +242,7 @@ export function HooksSection({ prefs, onChange }: Props) {
<PostHookCard
key={i}
hook={hook}
modelCatalog={modelCatalog}
onUpdate={(h) => {
const updated = [...postHooks];
updated[i] = h;
@@ -238,6 +265,7 @@ export function HooksSection({ prefs, onChange }: Props) {
<PreHookCard
key={i}
hook={hook}
modelCatalog={modelCatalog}
onUpdate={(h) => {
const updated = [...preHooks];
updated[i] = h;
+129
View File
@@ -0,0 +1,129 @@
// GSD Pi Config - Claude Code MCP per-model filters
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ClaudeCodeMcpPerModelEntry, GSDPreferences } from "../../types";
import { TextField, TagInput, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
}
function McpModelCard({
prefix,
entry,
onUpdate,
onRemove,
onRenamePrefix,
}: {
prefix: string;
entry: ClaudeCodeMcpPerModelEntry;
onUpdate: (e: ClaudeCodeMcpPerModelEntry) => void;
onRemove: () => void;
onRenamePrefix: (next: string) => void;
}) {
return (
<div className="p-3 rounded-lg bg-gsd-surface border border-gsd-border mb-3">
<div className="flex items-center justify-between gap-2 mb-2">
<TextField
value={prefix}
onChange={(v) => v && onRenamePrefix(v)}
className="font-mono text-sm flex-1"
/>
<button type="button" onClick={onRemove} className="text-xs text-gsd-danger hover:text-red-400 shrink-0">
Remove
</button>
</div>
<div className="space-y-2">
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Allowed servers</label>
<TagInput
values={entry.allowed_servers ?? []}
onChange={(allowed_servers) =>
onUpdate({
...entry,
allowed_servers: allowed_servers.length > 0 ? allowed_servers : undefined,
})
}
placeholder="server-name"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Blocked servers</label>
<TagInput
values={entry.blocked_servers ?? []}
onChange={(blocked_servers) =>
onUpdate({
...entry,
blocked_servers: blocked_servers.length > 0 ? blocked_servers : undefined,
})
}
placeholder="server-name"
/>
</div>
</div>
</div>
);
}
export function McpSection({ prefs, onChange }: Props) {
const perModel = prefs.claude_code_mcp?.per_model ?? {};
const entries = Object.entries(perModel);
const setPerModel = (next: Record<string, ClaudeCodeMcpPerModelEntry>) =>
onChange({
...prefs,
claude_code_mcp: {
...prefs.claude_code_mcp,
per_model: Object.keys(next).length > 0 ? next : undefined,
},
});
const addPrefix = () => {
const prefix = `model-${entries.length + 1}`;
setPerModel({ ...perModel, [prefix]: {} });
};
return (
<div>
<SectionHeader
title="Claude MCP"
description="Per-model MCP server allow/block lists (model ID prefix → servers)."
/>
<div className="flex items-center justify-between mt-2 mb-3">
<p className="text-xs text-gsd-text-dim">Keys are model ID prefixes matched longest-first.</p>
<button
type="button"
onClick={addPrefix}
className="text-xs px-2 py-1 rounded bg-gsd-accent/20 text-gsd-accent-hover hover:bg-gsd-accent/30 shrink-0"
>
+ Add prefix
</button>
</div>
{entries.length === 0 && (
<p className="text-xs text-gsd-text-dim mb-4">No per-model MCP filters configured.</p>
)}
{entries.map(([prefix, entry]) => (
<McpModelCard
key={prefix}
prefix={prefix}
entry={entry}
onUpdate={(e) => setPerModel({ ...perModel, [prefix]: e })}
onRenamePrefix={(newPrefix) => {
if (!newPrefix || newPrefix === prefix) return;
const { [prefix]: val, ...rest } = perModel;
setPerModel({ ...rest, [newPrefix]: val });
}}
onRemove={() => {
const { [prefix]: _drop, ...rest } = perModel;
void _drop;
setPerModel(rest);
}}
/>
))}
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD2 Config - Models Settings Section
// GSD Pi Config - Models Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type {
@@ -1,4 +1,4 @@
// GSD Setup - Notifications Settings Section
// GSD Pi Config - Notifications Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, NotificationPreferences } from "../../types";
+5 -4
View File
@@ -1,16 +1,17 @@
// GSD Setup - Parallel Execution Settings Section
// GSD Pi Config - Parallel Execution Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, ParallelConfig, MergeStrategy, AutoMergeMode } from "../../types";
import { Field, Toggle, SelectField, NumberField, ModelPicker, SectionHeader } from "../FormControls";
import { MODEL_CATALOG } from "../../constants";
import type { ProviderCatalog } from "../../constants";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
modelCatalog?: readonly ProviderCatalog[];
}
export function ParallelSection({ prefs, onChange }: Props) {
export function ParallelSection({ prefs, onChange, modelCatalog = [] }: Props) {
const par = prefs.parallel ?? {};
const setPar = (update: Partial<ParallelConfig>) =>
onChange({ ...prefs, parallel: { ...par, ...update } });
@@ -59,7 +60,7 @@ export function ParallelSection({ prefs, onChange }: Props) {
</Field>
<Field path="parallel.worker_model" label="Worker Model" description="Optional model override for parallel workers.">
<ModelPicker value={par.worker_model} onChange={(v) => setPar({ worker_model: v })} catalog={MODEL_CATALOG} placeholder="Default" />
<ModelPicker value={par.worker_model} onChange={(v) => setPar({ worker_model: v })} catalog={modelCatalog} placeholder="Default" />
</Field>
<h3 className="text-sm font-medium text-gsd-text-dim mt-6 mb-2 uppercase tracking-wider">Slice Parallel</h3>
+9 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Phases Settings Section
// GSD Pi Config - Phases Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, PhaseSkipPreferences } from "../../types";
@@ -44,6 +44,14 @@ export function PhasesSection({ prefs, onChange }: Props) {
<Field path="phases.require_slice_discussion" label="Require Slice Discussion" description="Pause before each slice for discussion.">
<Toggle checked={phases.require_slice_discussion ?? false} onChange={(v) => setPhases({ require_slice_discussion: v })} />
</Field>
<Field path="phases.mid_execution_escalation" label="Mid-Execution Escalation" description="Honor escalation payloads from complete-task (ADR-011 P2).">
<Toggle checked={phases.mid_execution_escalation ?? false} onChange={(v) => setPhases({ mid_execution_escalation: v })} />
</Field>
<Field path="phases.progressive_planning" label="Progressive Planning" description="Plan S01 fully; later slices as sketches until refined.">
<Toggle checked={phases.progressive_planning ?? false} onChange={(v) => setPhases({ progressive_planning: v })} />
</Field>
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Remote Questions Settings Section
// GSD Pi Config - Remote Questions Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, RemoteQuestionsConfig, RemoteChannel } from "../../types";
+99 -8
View File
@@ -1,16 +1,22 @@
// GSD Setup - Dynamic Routing Settings Section
// GSD Pi Config - Dynamic Routing Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, DynamicRoutingConfig } from "../../types";
import { Field, Toggle, ModelPicker, SectionHeader } from "../FormControls";
import { MODEL_CATALOG } from "../../constants";
import type { GSDPreferences, DynamicRoutingConfig, ModelCapabilityScores } from "../../types";
import { CATALOG_PROVIDER_IDS } from "../../constants";
import { Field, Toggle, ModelPicker, SectionHeader, MultiSelectField, NumberField, TextField } from "../FormControls";
import type { ProviderCatalog } from "../../constants";
const CAPABILITY_KEYS: (keyof ModelCapabilityScores)[] = [
"coding", "debugging", "research", "reasoning", "speed", "longContext", "instruction",
];
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
modelCatalog?: readonly ProviderCatalog[];
}
export function RoutingSection({ prefs, onChange }: Props) {
export function RoutingSection({ prefs, onChange, modelCatalog = [] }: Props) {
const routing = prefs.dynamic_routing ?? {};
const setRouting = (update: Partial<DynamicRoutingConfig>) =>
onChange({ ...prefs, dynamic_routing: { ...routing, ...update } });
@@ -48,13 +54,25 @@ export function RoutingSection({ prefs, onChange }: Props) {
<Toggle checked={routing.capability_routing ?? false} onChange={(v) => setRouting({ capability_routing: v })} />
</Field>
<Field path="dynamic_routing.allow_flat_rate_providers" label="Route Flat-Rate Providers" description="Allow dynamic routing for flat-rate providers (default: bypass).">
<Toggle checked={routing.allow_flat_rate_providers ?? false} onChange={(v) => setRouting({ allow_flat_rate_providers: v })} />
</Field>
<Field path="flat_rate_providers" label="Flat-Rate Provider IDs" description="Providers treated as flat-rate for routing guardrails.">
<MultiSelectField
values={prefs.flat_rate_providers ?? []}
onChange={(v) => onChange({ ...prefs, flat_rate_providers: v.length > 0 ? v : undefined })}
options={CATALOG_PROVIDER_IDS.map((id) => ({ value: id, label: id }))}
/>
</Field>
<h3 className="text-sm font-medium text-gsd-text-dim mt-4 mb-2 uppercase tracking-wider">Tier Models</h3>
<Field path="dynamic_routing.tier_models.light" label="Light" description="Model for simple/light tasks.">
<ModelPicker
value={tiers.light}
onChange={(v) => setRouting({ tier_models: { ...tiers, light: v } })}
catalog={MODEL_CATALOG}
catalog={modelCatalog}
placeholder="Default"
/>
</Field>
@@ -63,7 +81,7 @@ export function RoutingSection({ prefs, onChange }: Props) {
<ModelPicker
value={tiers.standard}
onChange={(v) => setRouting({ tier_models: { ...tiers, standard: v } })}
catalog={MODEL_CATALOG}
catalog={modelCatalog}
placeholder="Default"
/>
</Field>
@@ -72,10 +90,83 @@ export function RoutingSection({ prefs, onChange }: Props) {
<ModelPicker
value={tiers.heavy}
onChange={(v) => setRouting({ tier_models: { ...tiers, heavy: v } })}
catalog={MODEL_CATALOG}
catalog={modelCatalog}
placeholder="Default"
/>
</Field>
<h3 className="text-sm font-medium text-gsd-text-dim mt-6 mb-2 uppercase tracking-wider">Model Capability Overrides</h3>
<p className="text-xs text-gsd-text-dim mb-3">Per-model 7-D scores (0100) for capability-aware routing (ADR-004).</p>
{Object.entries(prefs.modelOverrides ?? {}).map(([modelId, override]) => (
<div key={modelId} className="p-3 rounded-lg bg-gsd-surface border border-gsd-border mb-3">
<div className="flex items-center justify-between mb-2">
<TextField
value={modelId}
onChange={(newId) => {
if (!newId || newId === modelId) return;
const { [modelId]: val, ...rest } = prefs.modelOverrides ?? {};
onChange({
...prefs,
modelOverrides: { ...rest, [newId]: val },
});
}}
className="font-mono text-sm w-48"
/>
<button
type="button"
className="text-xs text-gsd-danger"
onClick={() => {
const { [modelId]: _drop, ...rest } = prefs.modelOverrides ?? {};
void _drop;
onChange({
...prefs,
modelOverrides: Object.keys(rest).length > 0 ? rest : undefined,
});
}}
>
Remove
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{CAPABILITY_KEYS.map((key) => (
<div key={key}>
<label className="text-[10px] text-gsd-text-dim block mb-0.5">{key}</label>
<NumberField
value={override.capabilities?.[key]}
onChange={(v) => {
const caps = { ...override.capabilities, [key]: v };
onChange({
...prefs,
modelOverrides: {
...prefs.modelOverrides,
[modelId]: { capabilities: caps },
},
});
}}
min={0}
max={100}
placeholder="—"
/>
</div>
))}
</div>
</div>
))}
<button
type="button"
className="text-xs px-2 py-1 rounded bg-gsd-accent/20 text-gsd-accent-hover hover:bg-gsd-accent/30"
onClick={() => {
const id = `model-${Object.keys(prefs.modelOverrides ?? {}).length + 1}`;
onChange({
...prefs,
modelOverrides: { ...prefs.modelOverrides, [id]: { capabilities: {} } },
});
}}
>
+ Add model override
</button>
</div>
);
}
+10 -2
View File
@@ -1,8 +1,8 @@
// GSD Setup - Safety Harness Settings Section
// GSD Pi Config - Safety Harness Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, SafetyHarnessConfig } from "../../types";
import { Field, Toggle, NumberField, SectionHeader } from "../FormControls";
import { Field, Toggle, NumberField, TagInput, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
@@ -56,6 +56,14 @@ export function SafetySection({ prefs, onChange }: Props) {
<Field path="safety_harness.timeout_scale_cap" value={safety.timeout_scale_cap} label="Timeout Scale Cap" description="Maximum timeout scale factor (1-100).">
<NumberField value={safety.timeout_scale_cap} onChange={(v) => setSafety({ timeout_scale_cap: v })} min={1} max={100} placeholder="Default" />
</Field>
<Field path="safety_harness.file_change_allowlist" label="File Change Allowlist" description="Glob patterns exempt from file-change validation.">
<TagInput
values={safety.file_change_allowlist ?? []}
onChange={(v) => setSafety({ file_change_allowlist: v.length > 0 ? v : undefined })}
placeholder="e.g. docs/**"
/>
</Field>
</div>
);
}
@@ -1,9 +1,20 @@
// GSD2 Config - Skills Library Section
// GSD Pi Config - Skills Library Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useState, useEffect, useCallback, useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import { SectionHeader } from "../FormControls";
import {
btn,
btnPrimary,
btnSegment,
btnSegmentActive,
choiceBtn,
choiceBtnActive,
segmentGroup,
bannerDanger,
btnDanger,
} from "../../lib/uiClasses";
import { useConfigBackend } from "../../platform/backend";
export interface SkillInfo {
id: string;
@@ -21,6 +32,7 @@ interface Props {
type SaveState = "idle" | "saving" | "saved" | "error";
export function SkillsLibrarySection({ projectPath }: Props) {
const backend = useConfigBackend();
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [content, setContent] = useState<string>("");
@@ -36,8 +48,7 @@ export function SkillsLibrarySection({ projectPath }: Props) {
const loadSkills = useCallback(async () => {
try {
setError("");
const args = projectPath ? { projectPath } : {};
const list = await invoke<SkillInfo[]>("list_skills", args);
const list = await backend.listSkills(projectPath);
setSkills(list);
} catch (e) {
setError(String(e));
@@ -60,7 +71,7 @@ export function SkillsLibrarySection({ projectPath }: Props) {
}
(async () => {
try {
const text = await invoke<string>("read_skill", { path: selected.path });
const text = await backend.readSkill(selected.path);
setContent(text);
setOriginalContent(text);
setSaveState("idle");
@@ -91,7 +102,7 @@ export function SkillsLibrarySection({ projectPath }: Props) {
if (!selected) return;
setSaveState("saving");
try {
await invoke("write_skill", { path: selected.path, content });
await backend.writeSkill(selected.path, content);
setOriginalContent(content);
setSaveState("saved");
setTimeout(() => setSaveState("idle"), 1500);
@@ -109,7 +120,7 @@ export function SkillsLibrarySection({ projectPath }: Props) {
if (!selected) return;
if (!confirm(`Delete skill "${selected.name}"? This removes the entire skill folder.`)) return;
try {
await invoke("delete_skill", { path: selected.path });
await backend.deleteSkill(selected.path);
setSelectedId(null);
await loadSkills();
} catch (e) {
@@ -125,7 +136,11 @@ export function SkillsLibrarySection({ projectPath }: Props) {
scope: newSkillScope,
};
if (newSkillScope === "project" && projectPath) args.projectPath = projectPath;
const created = await invoke<SkillInfo>("create_skill", args);
const created = await backend.createSkill(
args.name,
args.scope,
args.projectPath,
);
setShowNewDialog(false);
setNewSkillName("");
await loadSkills();
@@ -142,20 +157,19 @@ export function SkillsLibrarySection({ projectPath }: Props) {
<div className="flex items-start justify-between mb-4">
<SectionHeader
title="Skills Library"
description="View and edit Claude Code / GSD skills. Skills are markdown files with YAML frontmatter that Claude loads on demand."
description="View and edit agent skills (GSD + Claude Code layout). Skills are markdown files with YAML frontmatter loaded on demand by the agent runtime."
/>
<button
onClick={() => setShowNewDialog(true)}
className="px-3 py-1.5 text-xs rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover transition-colors shrink-0"
>
<button type="button" onClick={() => setShowNewDialog(true)} className={`${btnPrimary} shrink-0`}>
+ New Skill
</button>
</div>
{error && (
<div className="mb-3 px-3 py-2 bg-gsd-danger/10 border border-gsd-danger/30 text-gsd-danger text-xs rounded flex items-center justify-between">
<div className={`${bannerDanger} mb-3 flex items-center justify-between text-xs`}>
<span>{error}</span>
<button onClick={() => setError("")} className="ml-2 hover:text-red-300">dismiss</button>
<button type="button" onClick={() => setError("")} className={`${btn} ml-2`}>
Dismiss
</button>
</div>
)}
@@ -177,25 +191,19 @@ export function SkillsLibrarySection({ projectPath }: Props) {
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Scope</label>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setNewSkillScope("global")}
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
newSkillScope === "global"
? "border-gsd-accent text-gsd-accent bg-gsd-accent-dim"
: "border-gsd-border text-gsd-text-dim hover:text-gsd-text"
}`}
className={newSkillScope === "global" ? choiceBtnActive : choiceBtn}
>
Global (~/.claude/skills)
</button>
<button
type="button"
onClick={() => setNewSkillScope("project")}
disabled={!hasProject}
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
newSkillScope === "project"
? "border-gsd-accent text-gsd-accent bg-gsd-accent-dim"
: "border-gsd-border text-gsd-text-dim hover:text-gsd-text"
} ${!hasProject ? "opacity-40 cursor-not-allowed" : ""}`}
className={newSkillScope === "project" ? choiceBtnActive : choiceBtn}
>
Project (.claude/skills)
</button>
@@ -206,16 +214,16 @@ export function SkillsLibrarySection({ projectPath }: Props) {
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => { setShowNewDialog(false); setNewSkillName(""); }}
className="px-3 py-1.5 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text"
type="button"
onClick={() => {
setShowNewDialog(false);
setNewSkillName("");
}}
className={btn}
>
Cancel
</button>
<button
onClick={createNew}
disabled={!newSkillName.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-gsd-accent text-gsd-on-accent font-medium hover:bg-gsd-accent-hover disabled:opacity-40 disabled:cursor-not-allowed"
>
<button type="button" onClick={createNew} disabled={!newSkillName.trim()} className={btnPrimary}>
Create
</button>
</div>
@@ -235,15 +243,14 @@ export function SkillsLibrarySection({ projectPath }: Props) {
placeholder="Search skills..."
className="w-full text-xs"
/>
<div className="flex rounded-md border border-gsd-border overflow-hidden text-[10px]">
<div className={`${segmentGroup} text-[10px]`}>
{(["all", "global", "project"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScopeFilter(s)}
className={`flex-1 px-2 py-1 uppercase tracking-wider font-medium transition-colors ${
scopeFilter === s
? "bg-gsd-accent text-gsd-on-accent"
: "text-gsd-text-dim hover:text-gsd-text"
className={`flex-1 uppercase tracking-wider font-medium ${
scopeFilter === s ? btnSegmentActive : btnSegment
}`}
>
{s}
@@ -304,28 +311,23 @@ export function SkillsLibrarySection({ projectPath }: Props) {
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={remove}
className="px-2 py-1 text-[10px] rounded border border-gsd-danger/40 text-gsd-danger hover:bg-gsd-danger/10"
>
<button type="button" onClick={remove} className={btnDanger}>
Delete
</button>
{isDirty && (
<button
onClick={discard}
className="px-2 py-1 text-[10px] rounded border border-gsd-border text-gsd-text-dim hover:text-gsd-text"
>
<button type="button" onClick={discard} className={btn}>
Discard
</button>
)}
<button
type="button"
onClick={save}
disabled={!isDirty || saveState === "saving"}
className={`px-3 py-1 text-[11px] rounded font-medium transition-colors ${
className={
isDirty
? "bg-gsd-accent text-gsd-on-accent hover:bg-gsd-accent-hover"
: "bg-gsd-border text-gsd-text-dim cursor-not-allowed"
}`}
? btnPrimary
: `${btn} !bg-gsd-border !text-gsd-text-dim !border-transparent`
}
>
{saveState === "saving" ? "Saving..." : saveState === "saved" ? "Saved" : "Save"}
</button>
+99 -5
View File
@@ -1,8 +1,63 @@
// GSD Setup - Skills Settings Section
// GSD Pi Config - Skills Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, SkillDiscoveryMode } from "../../types";
import { Field, SelectField, NumberField, TagInput, SectionHeader } from "../FormControls";
import type { GSDPreferences, SkillDiscoveryMode, GSDSkillRule } from "../../types";
import { CATALOG_PROVIDER_IDS, MODEL_CATALOG } from "../../constants";
import { Field, SelectField, NumberField, TagInput, SectionHeader, MultiSelectField } from "../FormControls";
function SkillRuleCard({
rule,
onUpdate,
onRemove,
}: {
rule: GSDSkillRule;
onUpdate: (r: GSDSkillRule) => void;
onRemove: () => void;
}) {
return (
<div className="p-3 rounded-lg bg-gsd-surface border border-gsd-border mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gsd-text-dim">When condition</span>
<button type="button" onClick={onRemove} className="text-xs text-gsd-danger hover:text-red-400">
Remove
</button>
</div>
<input
type="text"
value={rule.when}
onChange={(e) => onUpdate({ ...rule, when: e.target.value })}
placeholder="e.g. unit:execute-task"
className="w-full text-sm mb-2"
/>
<div className="space-y-2">
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Use</label>
<TagInput
values={rule.use ?? []}
onChange={(use) => onUpdate({ ...rule, use: use.length > 0 ? use : undefined })}
placeholder="Skill name"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Prefer</label>
<TagInput
values={rule.prefer ?? []}
onChange={(prefer) => onUpdate({ ...rule, prefer: prefer.length > 0 ? prefer : undefined })}
placeholder="Skill name"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Avoid</label>
<TagInput
values={rule.avoid ?? []}
onChange={(avoid) => onUpdate({ ...rule, avoid: avoid.length > 0 ? avoid : undefined })}
placeholder="Skill name"
/>
</div>
</div>
</div>
);
}
interface Props {
prefs: GSDPreferences;
@@ -13,6 +68,8 @@ export function SkillsSection({ prefs, onChange }: Props) {
const set = <K extends keyof GSDPreferences>(key: K, val: GSDPreferences[K]) =>
onChange({ ...prefs, [key]: val });
const skillRules = prefs.skill_rules ?? [];
return (
<div>
<SectionHeader
@@ -72,12 +129,49 @@ export function SkillsSection({ prefs, onChange }: Props) {
</Field>
<Field path="disabled_model_providers" label="Disabled Model Providers" description="Provider IDs to exclude from model selection.">
<TagInput
<MultiSelectField
values={prefs.disabled_model_providers ?? []}
onChange={(v) => set("disabled_model_providers", v.length > 0 ? v : undefined)}
placeholder="Add provider ID"
options={CATALOG_PROVIDER_IDS.map((id) => ({
value: id,
label: MODEL_CATALOG.find((p) => p.id === id)?.label ?? id,
}))}
/>
</Field>
<div className="flex items-center justify-between mt-6 mb-3">
<h3 className="text-sm font-medium text-gsd-text-dim uppercase tracking-wider">Conditional Rules</h3>
<button
type="button"
onClick={() =>
onChange({
...prefs,
skill_rules: [...skillRules, { when: "" }],
})
}
className="text-xs px-2 py-1 rounded bg-gsd-accent/20 text-gsd-accent-hover hover:bg-gsd-accent/30"
>
+ Add rule
</button>
</div>
{skillRules.length === 0 && (
<p className="text-xs text-gsd-text-dim mb-4">No conditional skill rules.</p>
)}
{skillRules.map((rule, i) => (
<SkillRuleCard
key={i}
rule={rule}
onUpdate={(r) => {
const next = [...skillRules];
next[i] = r;
onChange({ ...prefs, skill_rules: next });
}}
onRemove={() => {
const next = skillRules.filter((_, j) => j !== i);
onChange({ ...prefs, skill_rules: next.length > 0 ? next : undefined });
}}
/>
))}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
// GSD Pi Config - UOK (Unified Orchestration Kernel) Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences, UokPreferences, UokTurnActionMode } from "../../types";
import { Field, Toggle, SelectField, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
}
function setNestedToggle(
uok: UokPreferences,
group: keyof UokPreferences,
enabled: boolean,
): UokPreferences {
const prev = uok[group];
if (typeof prev === "object" && prev !== null) {
return { ...uok, [group]: { ...prev, enabled } };
}
return { ...uok, [group]: { enabled } };
}
export function UokSection({ prefs, onChange }: Props) {
const uok = prefs.uok ?? {};
const setUok = (patch: Partial<UokPreferences>) =>
onChange({ ...prefs, uok: { ...uok, ...patch } });
const gitops = uok.gitops ?? {};
return (
<div>
<SectionHeader
title="UOK"
description="Unified Orchestration Kernel — opt-out controls for gates, gitops, and planning."
/>
<Field path="uok.enabled" label="UOK Enabled">
<Toggle checked={uok.enabled ?? true} onChange={(v) => setUok({ enabled: v })} />
</Field>
<Field path="uok.legacy_fallback.enabled" label="Legacy Fallback">
<Toggle
checked={uok.legacy_fallback?.enabled ?? false}
onChange={(v) => setUok(setNestedToggle(uok, "legacy_fallback", v))}
/>
</Field>
<Field path="uok.gates.enabled" label="Gates">
<Toggle
checked={uok.gates?.enabled ?? true}
onChange={(v) => setUok(setNestedToggle(uok, "gates", v))}
/>
</Field>
<Field path="uok.model_policy.enabled" label="Model Policy">
<Toggle
checked={uok.model_policy?.enabled ?? true}
onChange={(v) => setUok(setNestedToggle(uok, "model_policy", v))}
/>
</Field>
<Field path="uok.execution_graph.enabled" label="Execution Graph">
<Toggle
checked={uok.execution_graph?.enabled ?? true}
onChange={(v) => setUok(setNestedToggle(uok, "execution_graph", v))}
/>
</Field>
<Field path="uok.audit_unified.enabled" label="Unified Audit">
<Toggle
checked={uok.audit_unified?.enabled ?? true}
onChange={(v) => setUok(setNestedToggle(uok, "audit_unified", v))}
/>
</Field>
<Field path="uok.plan_v2.enabled" label="Plan v2">
<Toggle
checked={uok.plan_v2?.enabled ?? true}
onChange={(v) => setUok(setNestedToggle(uok, "plan_v2", v))}
/>
</Field>
<h3 className="text-sm font-medium text-gsd-text-dim mt-6 mb-2 uppercase tracking-wider">GitOps</h3>
<Field path="uok.gitops.enabled" label="GitOps Enabled">
<Toggle
checked={gitops.enabled ?? true}
onChange={(v) => setUok({ gitops: { ...gitops, enabled: v } })}
/>
</Field>
<Field path="uok.gitops.turn_action" value={gitops.turn_action} label="Turn Action">
<SelectField<UokTurnActionMode>
value={gitops.turn_action}
onChange={(v) => setUok({ gitops: { ...gitops, turn_action: v } })}
options={["commit", "snapshot", "status-only"]}
placeholder="commit"
/>
</Field>
<Field path="uok.gitops.turn_push" label="Turn Push">
<Toggle
checked={gitops.turn_push ?? false}
onChange={(v) => setUok({ gitops: { ...gitops, turn_push: v } })}
/>
</Field>
</div>
);
}
@@ -1,4 +1,4 @@
// GSD Setup - Verification Settings Section
// GSD Pi Config - Verification Settings Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { GSDPreferences } from "../../types";
@@ -0,0 +1,143 @@
// GSD Pi Config - Multi-Repository Workspace Section
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type {
GSDPreferences,
WorkspacePreferences,
WorkspaceRepositoryPreference,
WorkspaceMode,
} from "../../types";
import { Field, SelectField, TextField, TagInput, SectionHeader } from "../FormControls";
interface Props {
prefs: GSDPreferences;
onChange: (prefs: GSDPreferences) => void;
}
function RepoCard({
id,
repo,
onUpdate,
onRemove,
}: {
id: string;
repo: WorkspaceRepositoryPreference;
onUpdate: (r: WorkspaceRepositoryPreference) => void;
onRemove: () => void;
}) {
return (
<div className="p-3 rounded-lg bg-gsd-surface border border-gsd-border mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gsd-text">{id}</span>
{id !== "project" && (
<button type="button" onClick={onRemove} className="text-xs text-gsd-danger hover:text-red-400">
Remove
</button>
)}
</div>
<div className="space-y-2">
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Path</label>
<TextField
value={repo.path}
onChange={(path) => onUpdate({ ...repo, path: path ?? "" })}
placeholder="relative/path"
className="w-full"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Role</label>
<TextField
value={repo.role}
onChange={(role) => onUpdate({ ...repo, role: role || undefined })}
placeholder="Optional"
className="w-full"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Verification commands</label>
<TagInput
values={repo.verification ?? []}
onChange={(verification) =>
onUpdate({ ...repo, verification: verification.length > 0 ? verification : undefined })
}
placeholder="Add command"
/>
</div>
<div>
<label className="text-xs text-gsd-text-dim block mb-1">Commit policy</label>
<SelectField<"auto" | "skip">
value={repo.commit_policy}
onChange={(commit_policy) => onUpdate({ ...repo, commit_policy })}
options={["auto", "skip"]}
placeholder="auto"
/>
</div>
</div>
</div>
);
}
export function WorkspaceSection({ prefs, onChange }: Props) {
const workspace = prefs.workspace ?? {};
const repos = workspace.repositories ?? {};
const entries = Object.entries(repos);
const setWorkspace = (patch: Partial<WorkspacePreferences>) =>
onChange({ ...prefs, workspace: { ...workspace, ...patch } });
const setRepos = (next: Record<string, WorkspaceRepositoryPreference>) =>
setWorkspace({ repositories: Object.keys(next).length > 0 ? next : undefined });
const addRepo = () => {
const id = `repo-${entries.length + 1}`;
setRepos({ ...repos, [id]: { path: "" } });
};
return (
<div>
<SectionHeader
title="Workspace"
description="Parent-workspace mode coordinates multiple repositories from one .gsd root."
/>
<Field path="workspace.mode" value={workspace.mode} label="Workspace Mode">
<SelectField<WorkspaceMode>
value={workspace.mode}
onChange={(v) => setWorkspace({ mode: v })}
options={["project", "parent"]}
placeholder="project"
/>
</Field>
<div className="flex items-center justify-between mt-4 mb-3">
<h3 className="text-sm font-medium text-gsd-text-dim uppercase tracking-wider">Repositories</h3>
<button
type="button"
onClick={addRepo}
className="text-xs px-2 py-1 rounded bg-gsd-accent/20 text-gsd-accent-hover hover:bg-gsd-accent/30"
>
+ Add repository
</button>
</div>
{entries.length === 0 && (
<p className="text-xs text-gsd-text-dim mb-4">No child repositories configured.</p>
)}
{entries.map(([id, repo]) => (
<RepoCard
key={id}
id={id}
repo={repo}
onUpdate={(r) => setRepos({ ...repos, [id]: r })}
onRemove={() => {
const { [id]: _drop, ...rest } = repos;
void _drop;
setRepos(rest);
}}
/>
))}
</div>
);
}
@@ -0,0 +1,561 @@
// GSD Pi Config - Sub-editors for settings.json
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useState, type ReactNode } from "react";
import { Field } from "../FormControls";
import type { BashInterceptorRule, FallbackChainEntry, HookEntry, HooksSettings } from "../../lib/agentSettingsTypes";
import { HOOK_EVENTS, type HookEventName } from "../../lib/agentSettings";
// ─── String list ─────────────────────────────────────────────────────────────
export interface StringListFieldProps {
label: string;
description?: string;
values: string[] | undefined;
onChange: (next: string[] | undefined) => void;
placeholder?: string;
wide?: boolean;
}
export function StringListField({
label,
description,
values,
onChange,
placeholder,
wide,
}: StringListFieldProps) {
const [draft, setDraft] = useState("");
const list = values ?? [];
const commit = (next: string[]) => {
onChange(next.length > 0 ? next : undefined);
};
const add = () => {
const trimmed = draft.trim();
if (!trimmed || list.includes(trimmed)) {
setDraft("");
return;
}
commit([...list, trimmed]);
setDraft("");
};
return (
<Field label={label} description={description}>
<div className={wide ? "w-full max-w-xl" : "w-80"}>
<div className="flex flex-wrap gap-1 mb-1.5 min-h-[1.25rem]">
{list.length === 0 && (
<span className="text-xs text-gsd-text-dim italic">empty</span>
)}
{list.map((v, i) => (
<span
key={`${v}-${i}`}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono rounded bg-gsd-accent/20 text-gsd-accent-hover"
>
{v}
<button
type="button"
onClick={() => commit(list.filter((_, j) => j !== i))}
className="text-gsd-text-dim hover:text-gsd-danger ml-0.5"
aria-label={`Remove ${v}`}
>
×
</button>
</span>
))}
</div>
<div className="flex gap-1">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder={placeholder}
className="flex-1 text-xs font-mono"
/>
<button
type="button"
onClick={add}
className="px-2 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover"
>
Add
</button>
</div>
</div>
</Field>
);
}
// ─── Environment variables ───────────────────────────────────────────────────
export function EnvEditor({
value,
onChange,
}: {
value: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}) {
const [draftKey, setDraftKey] = useState("");
const [draftVal, setDraftVal] = useState("");
const entries = Object.entries(value);
const add = () => {
const k = draftKey.trim();
if (!k) return;
onChange({ ...value, [k]: draftVal });
setDraftKey("");
setDraftVal("");
};
return (
<div>
{entries.length === 0 && (
<div className="text-xs text-gsd-text-dim italic mb-2">No environment variables set.</div>
)}
{entries.map(([k, v]) => (
<div key={k} className="flex items-center gap-2 mb-2">
<input type="text" value={k} readOnly className="w-52 font-mono text-xs bg-gsd-bg" />
<input
type="text"
value={v}
onChange={(e) => onChange({ ...value, [k]: e.target.value })}
className="flex-1 font-mono text-xs"
/>
<button
type="button"
onClick={() => {
const { [k]: _drop, ...rest } = value;
void _drop;
onChange(rest);
}}
className="px-2 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-danger"
>
×
</button>
</div>
))}
<div className="flex items-center gap-2 pt-2 border-t border-gsd-border">
<input
type="text"
value={draftKey}
onChange={(e) => setDraftKey(e.target.value)}
placeholder="KEY"
className="w-52 font-mono text-xs"
/>
<input
type="text"
value={draftVal}
onChange={(e) => setDraftVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder="value"
className="flex-1 font-mono text-xs"
/>
<button
type="button"
onClick={add}
disabled={!draftKey.trim()}
className="px-3 py-1 text-xs rounded-md border border-gsd-border text-gsd-text-dim hover:text-gsd-text hover:bg-gsd-surface-hover disabled:opacity-40"
>
Add
</button>
</div>
</div>
);
}
// ─── Hooks ───────────────────────────────────────────────────────────────────
function HookEntryCard({
entry,
onUpdate,
onRemove,
}: {
entry: HookEntry;
onUpdate: (e: HookEntry) => void;
onRemove: () => void;
}) {
return (
<div className="p-2 rounded border border-gsd-border/80 bg-gsd-bg/40 mb-2 text-xs space-y-2">
<div className="flex justify-between gap-2">
<input
type="text"
value={entry.command}
onChange={(e) => onUpdate({ ...entry, command: e.target.value })}
placeholder="Shell command"
className="flex-1 font-mono"
/>
<button type="button" onClick={onRemove} className="text-gsd-danger shrink-0">
Remove
</button>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-1 text-gsd-text-dim">
<input
type="checkbox"
checked={entry.blocking !== false}
onChange={(e) => onUpdate({ ...entry, blocking: e.target.checked })}
/>
Blocking
</label>
<label className="text-gsd-text-dim">
Timeout (ms)
<input
type="number"
value={entry.timeout ?? ""}
onChange={(e) =>
onUpdate({
...entry,
timeout: e.target.value ? Number(e.target.value) : undefined,
})
}
className="w-20 ml-1"
min={0}
/>
</label>
</div>
<input
type="text"
value={
Array.isArray(entry.match?.tool)
? entry.match.tool.join(", ")
: (entry.match?.tool ?? "")
}
onChange={(e) => {
const raw = e.target.value.trim();
const tool = raw.includes(",")
? raw.split(",").map((s) => s.trim()).filter(Boolean)
: raw || undefined;
onUpdate({
...entry,
match: { ...entry.match, tool: tool as string | string[] | undefined },
});
}}
placeholder="Match tool (optional, comma-separated)"
className="w-full font-mono"
/>
<input
type="text"
value={entry.match?.command ?? ""}
onChange={(e) =>
onUpdate({
...entry,
match: { ...entry.match, command: e.target.value || undefined },
})
}
placeholder="Match command prefix (optional)"
className="w-full font-mono"
/>
</div>
);
}
export function HooksEditor({
hooks,
onChangeEvent,
}: {
hooks: HooksSettings;
onChangeEvent: (event: HookEventName, entries: HookEntry[] | undefined) => void;
}) {
const [expanded, setExpanded] = useState<HookEventName | null>(null);
return (
<div className="space-y-2">
{HOOK_EVENTS.map((event) => {
const entries = hooks[event] ?? [];
const isOpen = expanded === event;
return (
<div key={event} className="rounded-lg border border-gsd-border overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-2 text-left text-xs font-mono bg-gsd-surface hover:bg-gsd-surface-hover"
onClick={() => setExpanded(isOpen ? null : event)}
>
<span>{event}</span>
<span className="text-gsd-text-dim">
{entries.length} hook{entries.length === 1 ? "" : "s"}
</span>
</button>
{isOpen && (
<div className="p-3 border-t border-gsd-border">
{entries.map((entry, i) => (
<HookEntryCard
key={i}
entry={entry}
onUpdate={(e) => {
const next = [...entries];
next[i] = e;
onChangeEvent(event, next);
}}
onRemove={() => {
const next = entries.filter((_, j) => j !== i);
onChangeEvent(event, next.length > 0 ? next : undefined);
}}
/>
))}
<button
type="button"
className="text-xs text-gsd-accent hover:text-gsd-accent-hover"
onClick={() =>
onChangeEvent(event, [
...entries,
{ command: "", blocking: true },
])
}
>
+ Add hook
</button>
</div>
)}
</div>
);
})}
</div>
);
}
// ─── Fallback chains ─────────────────────────────────────────────────────────
export function FallbackChainsEditor({
chains,
onChange,
}: {
chains: Record<string, FallbackChainEntry[]>;
onChange: (next: Record<string, FallbackChainEntry[]> | undefined) => void;
}) {
const names = Object.keys(chains);
const setChain = (name: string, entries: FallbackChainEntry[] | undefined) => {
const next = { ...chains };
if (!entries?.length) delete next[name];
else next[name] = entries;
onChange(Object.keys(next).length > 0 ? next : undefined);
};
const renameChain = (oldName: string, newName: string) => {
if (!newName || newName === oldName) return;
const { [oldName]: entries, ...rest } = chains;
onChange({ ...rest, [newName]: entries });
};
return (
<div className="space-y-3">
{names.length === 0 && (
<p className="text-xs text-gsd-text-dim">No fallback chains configured.</p>
)}
{names.map((name) => (
<div key={name} className="p-3 rounded-lg bg-gsd-surface border border-gsd-border">
<input
type="text"
defaultValue={name}
onBlur={(e) => renameChain(name, e.target.value.trim())}
className="text-sm font-mono font-medium mb-2 w-48"
/>
{(chains[name] ?? []).map((entry, i) => (
<div key={i} className="flex flex-wrap gap-2 mb-2 items-end">
<div>
<label className="text-[10px] text-gsd-text-dim">Provider</label>
<input
type="text"
value={entry.provider}
onChange={(e) => {
const list = [...chains[name]];
list[i] = { ...entry, provider: e.target.value };
setChain(name, list);
}}
className="block w-28 text-xs font-mono"
/>
</div>
<div>
<label className="text-[10px] text-gsd-text-dim">Model</label>
<input
type="text"
value={entry.model}
onChange={(e) => {
const list = [...chains[name]];
list[i] = { ...entry, model: e.target.value };
setChain(name, list);
}}
className="block w-36 text-xs font-mono"
/>
</div>
<div>
<label className="text-[10px] text-gsd-text-dim">Priority</label>
<input
type="number"
value={entry.priority}
onChange={(e) => {
const list = [...chains[name]];
list[i] = { ...entry, priority: Number(e.target.value) || 0 };
setChain(name, list);
}}
className="block w-16 text-xs"
min={0}
/>
</div>
<button
type="button"
className="text-xs text-gsd-danger pb-1"
onClick={() => {
const list = chains[name].filter((_, j) => j !== i);
setChain(name, list);
}}
>
×
</button>
</div>
))}
<div className="flex gap-2">
<button
type="button"
className="text-xs text-gsd-accent"
onClick={() =>
setChain(name, [
...(chains[name] ?? []),
{ provider: "", model: "", priority: (chains[name]?.length ?? 0) + 1 },
])
}
>
+ Entry
</button>
<button
type="button"
className="text-xs text-gsd-danger"
onClick={() => setChain(name, undefined)}
>
Delete chain
</button>
</div>
</div>
))}
<button
type="button"
className="text-xs px-2 py-1 rounded bg-gsd-accent/20 text-gsd-accent-hover"
onClick={() => {
const id = `chain-${names.length + 1}`;
onChange({ ...chains, [id]: [] });
}}
>
+ Add chain
</button>
</div>
);
}
// ─── Bash interceptor rules ────────────────────────────────────────────────
export function BashInterceptorRulesEditor({
rules,
onChange,
}: {
rules: BashInterceptorRule[];
onChange: (next: BashInterceptorRule[] | undefined) => void;
}) {
return (
<div className="space-y-2">
{rules.map((rule, i) => (
<div key={i} className="p-3 rounded-lg bg-gsd-surface border border-gsd-border text-xs space-y-2">
<div className="flex justify-between">
<span className="text-gsd-text-dim">Rule {i + 1}</span>
<button
type="button"
className="text-gsd-danger"
onClick={() => {
const next = rules.filter((_, j) => j !== i);
onChange(next.length > 0 ? next : undefined);
}}
>
Remove
</button>
</div>
<input
type="text"
value={rule.pattern}
onChange={(e) => {
const next = [...rules];
next[i] = { ...rule, pattern: e.target.value };
onChange(next);
}}
placeholder="Regex pattern"
className="w-full font-mono"
/>
<input
type="text"
value={rule.flags ?? ""}
onChange={(e) => {
const next = [...rules];
next[i] = { ...rule, flags: e.target.value || undefined };
onChange(next);
}}
placeholder="Regex flags (optional)"
className="w-24 font-mono"
/>
<input
type="text"
value={rule.tool}
onChange={(e) => {
const next = [...rules];
next[i] = { ...rule, tool: e.target.value };
onChange(next);
}}
placeholder="Replacement tool"
className="w-full"
/>
<textarea
value={rule.message}
onChange={(e) => {
const next = [...rules];
next[i] = { ...rule, message: e.target.value };
onChange(next);
}}
rows={2}
placeholder="Message shown when blocked"
className="w-full"
/>
</div>
))}
<button
type="button"
className="text-xs text-gsd-accent"
onClick={() =>
onChange([
...rules,
{ pattern: "", tool: "read", message: "" },
])
}
>
+ Add rule
</button>
</div>
);
}
export function SettingsGroup({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<>
<h3 className="mt-6 mb-1 text-xs font-semibold tracking-wide text-gsd-text uppercase">
{title}
</h3>
<div className="rounded-lg bg-gsd-surface border border-gsd-border px-4">
{children}
</div>
</>
);
}
+22 -4
View File
@@ -1,12 +1,13 @@
// GSD2 Config - Shared Constants (model catalog, commit types, etc.)
// GSD Pi Config - Shared Constants (model catalog, commit types, etc.)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
/**
* Provider-first model catalog. Each provider represents a distinct auth/routing path.
* The same model (e.g. claude-opus-4-6) can appear under multiple providers
* The same model ID can appear under multiple providers (e.g. gpt-4o via OpenAI
* vs Azure, or claude-opus-4-6 via Anthropic vs Bedrock) —
* the picker preserves which one was chosen so GSD can route correctly.
*
* Values are stored in `provider/model` prefix notation that GSD-2 supports,
* Values are stored in `provider/model` prefix notation that GSD Pi supports,
* or as `{provider, model}` pairs on GSDPhaseModelConfig.
*/
export interface ProviderCatalog {
@@ -74,7 +75,7 @@ export const MODEL_CATALOG: readonly ProviderCatalog[] = [
{
id: "vertex",
label: "Google Vertex AI",
description: "Claude & Gemini via GCP Vertex (GCP service account)",
description: "Multi-vendor models via GCP Vertex (service account)",
models: [
"claude-opus-4-6@vertex",
"claude-sonnet-4-6@vertex",
@@ -352,6 +353,23 @@ export function getProviderCatalog(id: string): ProviderCatalog | undefined {
return MODEL_CATALOG.find((p) => p.id === id);
}
/** Provider IDs from the model catalog (for multi-select UIs). */
export const CATALOG_PROVIDER_IDS = MODEL_CATALOG.map((p) => p.id);
/** Known slice-scoped gate names (free-form strings are also allowed elsewhere). */
export const KNOWN_SLICE_GATES = [
"verification",
"discussion",
"research",
"planning",
] as const;
/** Reactive execution isolation modes supported by GSD Pi. */
export const REACTIVE_ISOLATION_MODES = ["same-tree"] as const;
/** Git pre-merge check stored values (UI maps labels separately). */
export const GIT_PRE_MERGE_VALUES = ["true", "false", "auto"] as const;
/** Conventional commit types. */
export const COMMIT_TYPES = [
"feat",
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Per-section dirty tracking hook
// GSD Pi Config - Per-section dirty tracking hook
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Compares current prefs against the last-saved snapshot and reports which
+27
View File
@@ -0,0 +1,27 @@
// GSD Pi Config - matchMedia hook for responsive layout
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useEffect, useState } from "react";
/** True when the viewport matches `query` (updates on resize). */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mq = window.matchMedia(query);
const update = () => setMatches(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, [query]);
return matches;
}
/** Sidebar drawer layout (matches Tailwind `md` and `.gsd-sidebar-drawer`). */
export function useSidebarDrawerLayout(): boolean {
return useMediaQuery("(max-width: 767px)");
}
+452 -56
View File
@@ -1,7 +1,6 @@
/* GSD2 Config - Global Styles (matches gsd.build theme) */
/* GSD Pi Config - Global Styles (aligned with opengsd.net) */
/* Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net> */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@import "tailwindcss";
/* ──────────────────────────────────────────────────────────────────────────
@@ -19,52 +18,52 @@
* ────────────────────────────────────────────────────────────────────────── */
:root {
--gsd-bg: #000000;
--gsd-surface: rgba(255, 255, 255, 0.03);
--gsd-surface-solid: #0a0a0a;
--gsd-surface-hover: rgba(255, 255, 255, 0.05);
--gsd-border: #27272a; /* zinc-800 */
--gsd-border-strong: #3f3f46; /* zinc-700 */
--gsd-text: #ffffff;
--gsd-text-secondary: #a1a1aa; /* zinc-400 */
--gsd-text-dim: #71717a; /* zinc-500 */
--gsd-text-muted: #52525b; /* zinc-600 */
--gsd-accent: #7dcfff; /* brand cyan */
--gsd-accent-hover: rgba(124, 207, 255, 0.7);
--gsd-accent-dim: rgba(124, 207, 255, 0.15);
--gsd-accent-fg: #000000; /* foreground on accent backgrounds */
--gsd-success: #22c55e;
--gsd-warning: #eab308;
--gsd-danger: #ef4444;
/* opengsd.net dark tokens */
--gsd-bg: #050507;
--gsd-surface: rgba(231, 232, 240, 0.03);
--gsd-surface-solid: #0d0d14;
--gsd-surface-hover: rgba(231, 232, 240, 0.06);
--gsd-border: #1b1b2a;
--gsd-border-strong: #2a2a3d;
--gsd-text: #e7e8f0;
--gsd-text-secondary: #b4b5c9;
--gsd-text-dim: #8b8ca6;
--gsd-text-muted: #6b6c82;
--gsd-accent: #22d3ee;
--gsd-accent-hover: #38e0ff;
--gsd-accent-dim: rgba(34, 211, 238, 0.14);
--gsd-accent-fg: #050507;
--gsd-purple: #a855f7;
--gsd-success: #34d399;
--gsd-warning: #fbbf24;
--gsd-danger: #f87171;
/* Non-palette but theme-sensitive values referenced in global rules below */
--gsd-grid-line: rgba(255, 255, 255, 0.015);
--gsd-scrollbar-thumb: #27272a;
--gsd-scrollbar-thumb-hover: #52525b;
--gsd-grid-cyan: rgba(34, 211, 238, 0.05);
--gsd-grid-purple: rgba(168, 85, 247, 0.05);
--gsd-scrollbar-thumb: #2a2a3d;
--gsd-scrollbar-thumb-hover: #6b6c82;
--gsd-shell-offset: 3.5rem;
}
[data-theme="light"] {
--gsd-bg: #fafafa;
--gsd-surface: rgba(0, 0, 0, 0.025);
--gsd-surface-solid: #f4f4f5; /* zinc-100 */
--gsd-surface-hover: rgba(0, 0, 0, 0.05);
--gsd-border: #e4e4e7; /* zinc-200 */
--gsd-border-strong: #d4d4d8; /* zinc-300 */
--gsd-text: #18181b; /* zinc-900 */
--gsd-text-secondary: #52525b; /* zinc-600 */
--gsd-text-dim: #71717a; /* zinc-500 */
--gsd-text-muted: #a1a1aa; /* zinc-400 */
--gsd-accent: #0284c7; /* sky-600 — readable on white */
--gsd-accent-hover: rgba(2, 132, 199, 0.8);
--gsd-accent-dim: rgba(2, 132, 199, 0.12);
--gsd-accent-fg: #ffffff; /* white text on the darker accent */
--gsd-success: #15803d; /* green-700 */
--gsd-warning: #a16207; /* yellow-700 */
--gsd-danger: #dc2626; /* red-600 */
--gsd-grid-line: rgba(0, 0, 0, 0.03);
--gsd-scrollbar-thumb: #d4d4d8;
--gsd-scrollbar-thumb-hover: #a1a1aa;
--gsd-bg: #f6f6f9;
--gsd-surface: rgba(5, 5, 7, 0.03);
--gsd-surface-solid: #eeeff4;
--gsd-surface-hover: rgba(5, 5, 7, 0.06);
--gsd-border: #d8d9e6;
--gsd-border-strong: #c4c5d4;
--gsd-text: #12131a;
--gsd-text-secondary: #3d3e4f;
--gsd-text-dim: #5c5d70;
--gsd-text-muted: #7a7b8f;
--gsd-accent: #0891b2;
--gsd-accent-hover: #0e7490;
--gsd-accent-dim: rgba(8, 145, 178, 0.12);
--gsd-accent-fg: #f6f6f9;
--gsd-grid-cyan: rgba(8, 145, 178, 0.06);
--gsd-grid-purple: rgba(124, 58, 237, 0.05);
--gsd-scrollbar-thumb: #c4c5d4;
--gsd-scrollbar-thumb-hover: #7a7b8f;
}
@theme inline {
@@ -94,13 +93,16 @@ body {
margin: 0;
background: var(--gsd-bg);
color: var(--gsd-text);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: "Geist", "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
transition: background-color 0.2s, color 0.2s;
transition-property: background-color, color;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
/* Subtle grid background like gsd.build */
/* Grid background (opengsd.net grid-bg) */
body::before {
content: "";
position: fixed;
@@ -109,13 +111,19 @@ body::before {
width: 100%;
height: 100%;
background-image:
linear-gradient(var(--gsd-grid-line) 1px, transparent 0),
linear-gradient(90deg, var(--gsd-grid-line) 1px, transparent 0);
background-size: 50px 50px;
linear-gradient(90deg, var(--gsd-grid-purple) 1px, transparent 1px),
linear-gradient(var(--gsd-grid-cyan) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
code,
.font-mono,
textarea.font-mono {
font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
#root {
position: relative;
z-index: 1;
@@ -149,17 +157,26 @@ textarea {
padding: 6px 10px;
font-size: 13px;
line-height: 1.25;
height: 30px;
min-height: 40px;
height: 40px;
box-sizing: border-box;
outline: none;
transition: border-color 0.15s, background 0.15s;
transition-property: border-color, background-color, box-shadow;
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
font-family: inherit;
}
textarea {
min-height: 5rem;
height: auto;
}
input[type="search"] {
min-height: 40px;
height: 40px;
}
input[type="text"]:hover,
input[type="number"]:hover,
select:hover,
@@ -167,10 +184,19 @@ textarea:hover {
border-color: var(--gsd-border-strong);
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
input[type="text"]:focus-visible,
input[type="number"]:focus-visible,
select:focus-visible,
textarea:focus-visible {
border-color: var(--gsd-accent);
background: var(--gsd-surface-hover);
box-shadow: 0 0 0 2px var(--gsd-bg), 0 0 0 4px var(--gsd-accent-dim);
}
input[type="text"]:focus:not(:focus-visible),
input[type="number"]:focus:not(:focus-visible),
select:focus:not(:focus-visible),
textarea:focus:not(:focus-visible) {
border-color: var(--gsd-accent);
background: var(--gsd-surface-hover);
}
@@ -236,3 +262,373 @@ select option {
animation: gsd-field-flash 1.5s ease-out;
border-radius: 6px;
}
/* Help (?) control: 16px visible circle, 40px tap target via pseudo-element */
.gsd-hint-trigger::before {
content: "";
position: absolute;
inset: -12px;
}
/* ─── Interface feel (make-interfaces-feel-better) ─────────────────────────── */
.gsd-heading {
text-wrap: balance;
}
.gsd-prose {
text-wrap: pretty;
}
.tabular-nums,
.gsd-tabular-nums {
font-variant-numeric: tabular-nums;
}
.gsd-img-outline {
outline: 1px solid rgba(255, 255, 255, 0.1);
outline-offset: 0;
}
[data-theme="light"] .gsd-img-outline {
outline-color: rgba(0, 0, 0, 0.1);
}
/* Layered shadow instead of a heavy flat border on elevated panels */
.gsd-field-control {
display: flex;
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
@media (min-width: 640px) {
.gsd-field-control {
width: auto;
align-items: flex-end;
}
}
.gsd-modal-panel {
border-radius: 12px;
border: 1px solid var(--gsd-border);
background: var(--gsd-surface-solid);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06),
0 16px 48px rgba(0, 0, 0, 0.45);
animation: gsd-panel-enter 0.22s cubic-bezier(0.2, 0, 0, 1);
}
[data-theme="light"] .gsd-modal-panel {
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.06),
0 16px 40px rgba(0, 0, 0, 0.12);
}
@keyframes gsd-panel-enter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Concentric: modal 12px + ~16px padding → inner controls 6px (rounded-md) */
.gsd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
min-height: 40px;
padding: 0 12px;
font-size: 12px;
line-height: 1.25;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--gsd-border);
color: var(--gsd-text-dim);
background: transparent;
cursor: pointer;
transition-property: color, background-color, border-color, transform, opacity;
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.gsd-btn:hover:not(:disabled) {
color: var(--gsd-text);
background: var(--gsd-surface-hover);
border-color: var(--gsd-border-strong);
}
.gsd-btn:active:not(:disabled) {
transform: scale(0.96);
}
.gsd-btn:focus-visible,
.gsd-btn-primary:focus-visible,
.gsd-btn-segment:focus-visible,
.gsd-nav-item:focus-visible,
.gsd-choice-btn:focus-visible,
.gsd-hint-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--gsd-bg), 0 0 0 4px var(--gsd-accent);
}
.gsd-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.gsd-btn-primary {
border-color: transparent;
background: var(--gsd-accent);
color: var(--gsd-accent-fg);
}
.gsd-btn-primary:hover:not(:disabled) {
background: var(--gsd-accent-hover);
color: var(--gsd-accent-fg);
}
.gsd-btn-segment {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 12px;
border-radius: 0;
border: none;
background: var(--gsd-bg);
color: var(--gsd-text-dim);
text-decoration: none;
box-sizing: border-box;
}
.gsd-btn-segment:hover:not(:disabled) {
color: var(--gsd-text);
background: var(--gsd-surface-hover);
}
.gsd-btn-segment-active {
background: var(--gsd-accent);
color: var(--gsd-accent-fg);
}
.gsd-btn-segment-active:hover:not(:disabled) {
background: var(--gsd-accent-hover);
color: var(--gsd-accent-fg);
}
.gsd-nav-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 40px;
padding: 0 12px;
text-align: left;
font-size: 14px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
transition-property: color, background-color, transform;
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.gsd-nav-item:active:not(:disabled) {
transform: scale(0.96);
}
.gsd-nav-item-active {
background: var(--gsd-accent-dim);
color: var(--gsd-accent);
font-weight: 500;
}
.gsd-nav-item-idle {
color: var(--gsd-text-dim);
}
.gsd-nav-item-idle:hover {
color: var(--gsd-text);
background: var(--gsd-surface-hover);
}
.gsd-local-chrome {
display: flex;
min-height: 3.25rem;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--gsd-border);
}
/* Mobile section drawer sits below WebShell header */
@media (max-width: 767px) {
.gsd-sidebar-drawer {
top: var(--gsd-shell-offset, 3.5rem);
}
}
/* Card surfaces: soft depth */
.gsd-card {
border-radius: 12px;
background: var(--gsd-surface);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 4px 16px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .gsd-card {
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.05),
0 4px 16px rgba(0, 0, 0, 0.06);
}
/* Large choice tiles (wizard mode/profile) */
.gsd-choice-btn {
min-height: 48px;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid var(--gsd-border);
background: transparent;
color: var(--gsd-text-dim);
cursor: pointer;
text-align: left;
transition-property: color, background-color, border-color, transform;
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.gsd-choice-btn:hover {
border-color: var(--gsd-border-strong);
color: var(--gsd-text);
}
.gsd-choice-btn:active {
transform: scale(0.96);
}
.gsd-choice-btn-active {
border-color: var(--gsd-accent);
background: var(--gsd-accent-dim);
color: var(--gsd-accent);
}
.gsd-dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 40px;
padding: 0 10px;
font-size: 12px;
line-height: 1.25;
border-radius: 6px;
border: 1px solid var(--gsd-border);
background: var(--gsd-surface-solid);
color: var(--gsd-text);
cursor: pointer;
transition-property: border-color, background-color, box-shadow;
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.gsd-dropdown-trigger:hover {
border-color: var(--gsd-border-strong);
background: var(--gsd-surface-hover);
}
.gsd-dropdown-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--gsd-bg), 0 0 0 4px var(--gsd-accent);
}
.gsd-dropdown-panel {
position: absolute;
left: 0;
right: 0;
z-index: 50;
margin-top: 4px;
max-height: 14rem;
overflow-y: auto;
border-radius: 6px;
border: 1px solid var(--gsd-border-strong);
background: var(--gsd-surface-solid);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 8px 24px rgba(0, 0, 0, 0.35);
}
.gsd-dropdown-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
font-size: 12px;
color: var(--gsd-text);
cursor: pointer;
transition: background-color 0.1s;
}
.gsd-dropdown-option:hover {
background: var(--gsd-surface-hover);
}
.gsd-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
padding: 0.125rem 0.375rem 0.125rem 0.5rem;
font-size: 10px;
line-height: 1.25;
border-radius: 4px;
border: 1px solid var(--gsd-border);
background: var(--gsd-surface);
color: var(--gsd-text-dim);
}
.gsd-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
border: none;
border-radius: 2px;
background: transparent;
color: var(--gsd-text-muted);
cursor: pointer;
line-height: 1;
}
.gsd-chip-remove:hover {
color: var(--gsd-text);
background: var(--gsd-surface-hover);
}
@media (prefers-reduced-motion: reduce) {
.gsd-modal-panel {
animation: none;
}
.gsd-btn:active:not(:disabled),
.gsd-nav-item:active:not(:disabled),
.gsd-choice-btn:active,
.gsd-hint-trigger:active {
transform: none;
}
[data-field-path].gsd-field-focus {
animation: none;
box-shadow: 0 0 0 3px var(--gsd-accent-dim);
}
}
+144
View File
@@ -0,0 +1,144 @@
// GSD Pi Config - settings.json helpers
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { AgentSettingsDoc, HooksSettings, HookEntry, PiAgentSettings } from "./agentSettingsTypes";
export const HOOK_EVENTS = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"SessionStart",
"SessionEnd",
"Stop",
"Notification",
"PreCompact",
"PostCompact",
"PreCommit",
"PostCommit",
"PrePush",
"PostPush",
"PrePr",
"PostPr",
"PreMilestone",
"PostMilestone",
"PreUnit",
"PostUnit",
"PreVerify",
"PostVerify",
"BudgetThreshold",
"Blocked",
] as const;
export type HookEventName = (typeof HOOK_EVENTS)[number];
export function asAgentSettings(doc: Record<string, unknown>): AgentSettingsDoc {
return doc as AgentSettingsDoc;
}
/** Set or remove a top-level key. */
export function setKey(
doc: Record<string, unknown>,
key: string,
next: unknown,
): Record<string, unknown> {
if (next === undefined) {
const { [key]: _drop, ...rest } = doc;
void _drop;
return rest;
}
return { ...doc, [key]: next };
}
/** Shallow-merge a nested object key on the document. */
export function patchNested<T extends object>(
doc: Record<string, unknown>,
key: keyof PiAgentSettings,
patch: Partial<T>,
): Record<string, unknown> {
const prev = doc[key as string];
const base =
prev && typeof prev === "object" && !Array.isArray(prev)
? (prev as T)
: ({} as T);
const merged = { ...base, ...patch };
const cleaned = Object.fromEntries(
Object.entries(merged).filter(([, v]) => v !== undefined),
) as T;
return setKey(
doc,
key as string,
Object.keys(cleaned).length > 0 ? cleaned : undefined,
);
}
/** Model picker value ↔ defaultProvider + defaultModel (clears legacy `model`). */
export function defaultModelPickerValue(doc: Record<string, unknown>): string | undefined {
const s = asAgentSettings(doc);
if (s.defaultModel) {
return s.defaultProvider ? `${s.defaultProvider}/${s.defaultModel}` : s.defaultModel;
}
return s.model;
}
export function applyDefaultModelPicker(
doc: Record<string, unknown>,
value: string | undefined,
): Record<string, unknown> {
if (!value?.trim()) {
let next = setKey(doc, "defaultModel", undefined);
next = setKey(next, "defaultProvider", undefined);
return next;
}
const slash = value.indexOf("/");
let next = setKey(doc, "model", undefined);
if (slash > 0) {
next = setKey(next, "defaultProvider", value.slice(0, slash));
next = setKey(next, "defaultModel", value.slice(slash + 1));
} else {
next = setKey(next, "defaultProvider", undefined);
next = setKey(next, "defaultModel", value);
}
return next;
}
export function readHooks(doc: Record<string, unknown>): HooksSettings {
const hooks = doc.hooks;
if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) {
return hooks as HooksSettings;
}
return {};
}
export function setHookEvent(
doc: Record<string, unknown>,
event: HookEventName,
entries: HookEntry[] | undefined,
): Record<string, unknown> {
const hooks = readHooks(doc);
const nextHooks = { ...hooks, [event]: entries };
if (!entries?.length) {
delete nextHooks[event];
}
return setKey(
doc,
"hooks",
Object.keys(nextHooks).length > 0 ? nextHooks : undefined,
);
}
export function packageSourceLabels(packages: PiAgentSettings["packages"]): string[] {
if (!packages) return [];
return packages.map((p) =>
typeof p === "string" ? p : p.source,
);
}
export function setPackageSourcesFromLabels(
doc: Record<string, unknown>,
labels: string[],
): Record<string, unknown> {
const prev = asAgentSettings(doc).packages ?? [];
const objectEntries = prev.filter((p) => typeof p !== "string");
const next = [...labels, ...objectEntries];
return setKey(doc, "packages", next.length > 0 ? next : undefined);
}
+225
View File
@@ -0,0 +1,225 @@
// GSD Pi Config - pi-coding-agent settings.json types (mirrors settings-manager.ts)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type TransportSetting = "sse" | "websocket" | "auto";
export type SteeringMode = "all" | "one-at-a-time";
export type AdaptiveTuiMode = "auto" | "chat" | "workflow" | "validation" | "debug" | "compact";
export type DoubleEscapeAction = "fork" | "tree" | "none";
export type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
export type EditMode = "standard" | "hashline";
export type TimestampFormat = "date-time-iso" | "date-time-us";
export type TaskIsolationMode = "none" | "worktree" | "fuse-overlay";
export type TaskIsolationMerge = "patch" | "branch";
export interface CompactionSettings {
enabled?: boolean;
reserveTokens?: number;
keepRecentTokens?: number;
thresholdPercent?: number;
}
export interface BranchSummarySettings {
reserveTokens?: number;
skipPrompt?: boolean;
}
export interface RetrySettings {
enabled?: boolean;
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
export interface TerminalSettings {
showImages?: boolean;
clearOnShrink?: boolean;
adaptiveMode?: AdaptiveTuiMode;
}
export interface ImageSettings {
autoResize?: boolean;
blockImages?: boolean;
}
export interface ThinkingBudgetsSettings {
minimal?: number;
low?: number;
medium?: number;
high?: number;
}
export interface BashInterceptorRule {
pattern: string;
flags?: string;
tool: string;
message: string;
}
export interface BashInterceptorSettings {
enabled?: boolean;
rules?: BashInterceptorRule[];
}
export interface MarkdownSettings {
codeBlockIndent?: string;
}
export interface MemorySettings {
enabled?: boolean;
maxRolloutsPerStartup?: number;
maxRolloutAgeDays?: number;
minRolloutIdleHours?: number;
stage1Concurrency?: number;
summaryInjectionTokenLimit?: number;
}
export interface AsyncSettings {
enabled?: boolean;
maxJobs?: number;
}
export interface TaskIsolationSettings {
mode?: TaskIsolationMode;
merge?: TaskIsolationMerge;
}
export interface FallbackChainEntry {
provider: string;
model: string;
priority: number;
}
export interface FallbackSettings {
enabled?: boolean;
chains?: Record<string, FallbackChainEntry[]>;
}
export interface ModelDiscoverySettings {
enabled?: boolean;
providers?: string[];
ttlMinutes?: number;
autoRefreshOnModelSelect?: boolean;
}
export interface HookEntry {
match?: {
tool?: string | string[];
command?: string;
};
command: string;
timeout?: number;
blocking?: boolean;
env?: Record<string, string>;
}
export interface HooksSettings {
PreToolUse?: HookEntry[];
PostToolUse?: HookEntry[];
UserPromptSubmit?: HookEntry[];
SessionStart?: HookEntry[];
SessionEnd?: HookEntry[];
Stop?: HookEntry[];
Notification?: HookEntry[];
PreCompact?: HookEntry[];
PostCompact?: HookEntry[];
PreCommit?: HookEntry[];
PostCommit?: HookEntry[];
PrePush?: HookEntry[];
PostPush?: HookEntry[];
PrePr?: HookEntry[];
PostPr?: HookEntry[];
PreMilestone?: HookEntry[];
PostMilestone?: HookEntry[];
PreUnit?: HookEntry[];
PostUnit?: HookEntry[];
PreVerify?: HookEntry[];
PostVerify?: HookEntry[];
BudgetThreshold?: HookEntry[];
Blocked?: HookEntry[];
}
export type PackageSource =
| string
| {
source: string;
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
};
/** pi-coding-agent `Settings` — primary schema for ~/.gsd/agent/settings.json */
export interface PiAgentSettings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
defaultThinkingLevel?: ThinkingLevel;
transport?: TransportSetting;
steeringMode?: SteeringMode;
followUpMode?: SteeringMode;
theme?: string;
compaction?: CompactionSettings;
branchSummary?: BranchSummarySettings;
retry?: RetrySettings;
hideThinkingBlock?: boolean;
shellPath?: string;
quietStartup?: boolean;
shellCommandPrefix?: string;
collapseChangelog?: boolean;
packages?: PackageSource[];
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
enableSkillCommands?: boolean;
terminal?: TerminalSettings;
images?: ImageSettings;
enabledModels?: string[];
doubleEscapeAction?: DoubleEscapeAction;
treeFilterMode?: TreeFilterMode;
thinkingBudgets?: ThinkingBudgetsSettings;
editorPaddingX?: number;
autocompleteMaxVisible?: number;
respectGitignoreInPicker?: boolean;
searchExcludeDirs?: string[];
showHardwareCursor?: boolean;
markdown?: MarkdownSettings;
memory?: MemorySettings;
async?: AsyncSettings;
bashInterceptor?: BashInterceptorSettings;
taskIsolation?: TaskIsolationSettings;
fallback?: FallbackSettings;
modelDiscovery?: ModelDiscoverySettings;
editMode?: EditMode;
timestampFormat?: TimestampFormat;
allowedCommandPrefixes?: string[];
fetchAllowedUrls?: string[];
hooks?: HooksSettings;
}
/** Claude Codecompatible keys that may coexist in the same file */
export interface ClaudeCodeSettingsExtension {
model?: string;
apiKeyHelper?: string;
env?: Record<string, string>;
permissions?: {
defaultMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan" | "auto";
allow?: string[];
deny?: string[];
ask?: string[];
};
statusLine?: {
type?: "command" | "static_text";
command?: string;
padding?: number;
};
outputStyle?: string;
includeCoAuthoredBy?: boolean;
cleanupPeriodDays?: number;
verbose?: boolean;
autoUpdates?: boolean;
alwaysThinkingEnabled?: boolean;
}
export type AgentSettingsDoc = PiAgentSettings & ClaudeCodeSettingsExtension;
+25
View File
@@ -0,0 +1,25 @@
// GSD Pi Config - Strip empty values before YAML export
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
/**
* Strip undefined/null values and empty objects recursively for clean YAML output.
*
* Known limitation: empty arrays are pruned (a user clearing a list to `[]`
* loses the key on save). Intentional for now — users don't typically
* distinguish "unset" from "empty" for these fields.
*/
export function cleanPrefs(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
if (val === undefined || val === null) continue;
if (Array.isArray(val)) {
if (val.length > 0) result[key] = val;
} else if (typeof val === "object") {
const cleaned = cleanPrefs(val as Record<string, unknown>);
if (Object.keys(cleaned).length > 0) result[key] = cleaned;
} else {
result[key] = val;
}
}
return result;
}
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Custom provider merge + collision detection
// GSD Pi Config - Custom provider merge + collision detection
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// `models.json` lets users register custom providers alongside the built-in
+37
View File
@@ -0,0 +1,37 @@
// GSD Pi Config - Download config files to the user's machine (web)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { cleanPrefs } from "./cleanPrefs";
import { serializePreferences } from "./preferencesCore";
import type { GSDModelsConfig, GSDPreferences } from "../types";
export interface WorkspaceDownload {
preferences: GSDPreferences;
models: GSDModelsConfig;
settings: Record<string, unknown>;
}
function downloadBlob(filename: string, content: string, mime: string): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/** Trigger browser downloads for the three GSD config files. */
export function downloadWorkspaceFiles(workspace: WorkspaceDownload): void {
const cleaned = cleanPrefs(
workspace.preferences as unknown as Record<string, unknown>,
);
const preferencesMd = serializePreferences(cleaned as GSDPreferences);
downloadBlob("preferences.md", preferencesMd, "text/markdown;charset=utf-8");
const modelsJson = JSON.stringify(workspace.models, null, 2);
downloadBlob("models.json", modelsJson, "application/json;charset=utf-8");
const settingsJson = JSON.stringify(workspace.settings, null, 2);
downloadBlob("settings.json", settingsJson, "application/json;charset=utf-8");
}
+88 -6
View File
@@ -1,4 +1,4 @@
// GSD Setup - Field Registry (single source of truth for palette, dirty tracking, validation, hints)
// GSD Pi Config - Field Registry (single source of truth for palette, dirty tracking, validation, hints)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// The registry maps a dotted JSON path (e.g. "git.auto_push") to metadata:
@@ -40,15 +40,25 @@ const registry = {
validator: isEnum(["solo", "team"]) },
"token_profile": { section: "general", label: "Token Profile", type: "enum",
hint: "Coordinates model selection, phase skipping, and compression.",
validator: isEnum(["budget", "balanced", "quality"]) },
validator: isEnum(["budget", "balanced", "quality", "burn-max"]) },
"planning_depth": { section: "general", label: "Planning Depth", type: "enum",
hint: "light: single discuss session; deep: staged PROJECT → REQUIREMENTS → CONTEXT → ROADMAP.",
validator: isEnum(["light", "deep"]) },
"language": { section: "general", label: "Response Language", type: "text",
hint: "Language for agent responses (e.g. English, Spanish)." },
"min_request_interval_ms": { section: "general", label: "Min Request Interval (ms)", type: "number",
hint: "Minimum ms between auto-mode LLM requests. 0 disables.", validator: numInRange(0, 600_000) },
"search_provider": { section: "general", label: "Search Provider", type: "enum",
hint: "Search backend. auto uses the default provider.",
validator: isEnum(["brave", "tavily", "ollama", "native", "auto"]) },
"widget_mode": { section: "general", label: "Widget Mode", type: "enum",
hint: "Widget display size for the auto-mode dashboard.",
validator: isEnum(["full", "small", "min", "off"]) },
"context_selection": { section: "general", label: "Context Selection", type: "enum",
hint: "full inlines whole files; smart uses semantic chunking.",
validator: isEnum(["full", "smart"]) },
"service_tier": { section: "general", label: "Service Tier", type: "enum",
hint: "OpenAI tier. priority = 2x cost/faster, flex = 0.5x cost/slower.",
hint: "Provider latency tier when supported (e.g. OpenAI priority/flex). priority costs more, flex costs less.",
validator: isEnum(["priority", "flex"]) },
"unique_milestone_ids": { section: "general", label: "Unique Milestone IDs", type: "bool",
hint: "Generate milestone IDs in M{seq}-{rand6} format." },
@@ -63,7 +73,7 @@ const registry = {
// ─── Models ─────────────────────────────────────────────────────────────
"models": { section: "models", label: "Model Overrides", type: "object",
keywords: ["claude", "gpt", "provider"] },
keywords: ["openai", "gemini", "anthropic", "gpt", "provider", "model"] },
"models.research": { section: "models", label: "Research Model", type: "text" },
"models.planning": { section: "models", label: "Planning Model", type: "text" },
"models.discuss": { section: "models", label: "Discussion Model", type: "text" },
@@ -79,7 +89,8 @@ const registry = {
"git.push_branches": { section: "git", label: "Push Branches", type: "bool" },
"git.remote": { section: "git", label: "Remote", type: "text", example: "origin" },
"git.snapshots": { section: "git", label: "Snapshots", type: "bool" },
"git.pre_merge_check": { section: "git", label: "Pre-Merge Check", type: "enum" },
"git.pre_merge_check": { section: "git", label: "Pre-Merge Check", type: "enum",
validator: isEnum(["true", "false", "auto"]) },
"git.commit_type": { section: "git", label: "Commit Type", type: "enum",
hint: "Conventional commit prefix; inferred from diff by default." },
"git.main_branch": { section: "git", label: "Main Branch", type: "text", example: "main" },
@@ -92,6 +103,12 @@ const registry = {
validator: validPath },
"git.auto_pr": { section: "git", label: "Auto PR", type: "bool" },
"git.pr_target_branch": { section: "git", label: "PR Target Branch", type: "text", example: "main" },
"git.absorb_snapshot_commits": { section: "git", label: "Absorb Snapshot Commits", type: "bool",
hint: "Squash gsd snapshot commits into the next real commit." },
"git.collapse_cadence": { section: "git", label: "Collapse Cadence", type: "enum",
validator: isEnum(["milestone", "slice"]) },
"git.milestone_resquash": { section: "git", label: "Milestone Resquash", type: "bool",
hint: "When collapse_cadence is slice, re-squash to one commit per milestone at end." },
// ─── Skills ─────────────────────────────────────────────────────────────
"always_use_skills": { section: "skills", label: "Always Use Skills", type: "list",
@@ -113,6 +130,10 @@ const registry = {
validator: isEnum(["warn", "pause", "halt"]) },
"context_pause_threshold": { section: "budget", label: "Context Pause Threshold", type: "number",
hint: "Percent of context window before pausing.", validator: numInRange(0, 100) },
"per_unit_cost_cap_usd": { section: "budget", label: "Per-Unit Cost Cap ($)", type: "number",
validator: numInRange(0, 10_000) },
"flat_rate_providers": { section: "routing", label: "Flat-Rate Providers", type: "list",
hint: "Provider IDs billed at flat rate (used with dynamic routing)." },
// ─── Notifications ──────────────────────────────────────────────────────
"notifications.enabled": { section: "notifications", label: "Notifications Enabled", type: "bool" },
@@ -138,7 +159,8 @@ const registry = {
"reactive_execution.enabled": { section: "parallel", label: "Reactive Execution", type: "bool" },
"reactive_execution.max_parallel": { section: "parallel", label: "Reactive Max Parallel", type: "number",
validator: numInRange(1, 16) },
"reactive_execution.isolation_mode": { section: "parallel", label: "Reactive Isolation", type: "enum" },
"reactive_execution.isolation_mode": { section: "parallel", label: "Reactive Isolation", type: "enum",
validator: isEnum(["same-tree"]) },
"reactive_execution.subagent_model": { section: "parallel", label: "Reactive Subagent Model", type: "text" },
// ─── Phases ─────────────────────────────────────────────────────────────
@@ -148,6 +170,10 @@ const registry = {
"phases.skip_milestone_validation": { section: "phases", label: "Skip Milestone Validation", type: "bool" },
"phases.reassess_after_slice": { section: "phases", label: "Reassess After Slice", type: "bool" },
"phases.require_slice_discussion": { section: "phases", label: "Require Slice Discussion", type: "bool" },
"phases.mid_execution_escalation": { section: "phases", label: "Mid-Execution Escalation", type: "bool",
hint: "Allow complete-task escalation payloads (ADR-011 P2)." },
"phases.progressive_planning": { section: "phases", label: "Progressive Planning", type: "bool",
hint: "Plan S01 fully; S02+ as sketches until refined." },
"gate_evaluation.enabled": { section: "phases", label: "Gate Evaluation", type: "bool" },
"gate_evaluation.slice_gates": { section: "phases", label: "Slice Gates", type: "list" },
"gate_evaluation.task_gates": { section: "phases", label: "Task Gates", type: "bool" },
@@ -160,6 +186,18 @@ const registry = {
validator: numInRange(0, 100) },
"context_management.tool_result_max_chars": { section: "context", label: "Tool Result Max Chars", type: "number",
validator: numInRange(0, 1_000_000) },
"context_window_override": { section: "context", label: "Context Window Override", type: "number",
hint: "Token limit for prompt budget when registry cannot resolve runtime window.",
validator: numInRange(1_000, 10_000_000) },
"context_mode.enabled": { section: "context", label: "Context Mode (gsd_exec)", type: "bool",
hint: "Tool-output sandboxing via subprocess digest. Default on unless false." },
"context_mode.exec_timeout_ms": { section: "context", label: "Exec Timeout (ms)", type: "number",
validator: numInRange(1_000, 600_000) },
"context_mode.exec_stdout_cap_bytes": { section: "context", label: "Exec Stdout Cap (bytes)", type: "number",
validator: numInRange(4_096, 16_777_216) },
"context_mode.exec_digest_chars": { section: "context", label: "Exec Digest Chars", type: "number",
validator: numInRange(0, 4_000) },
"context_mode.exec_env_allowlist": { section: "context", label: "Exec Env Allowlist", type: "list" },
// ─── Dynamic Routing ────────────────────────────────────────────────────
"dynamic_routing.enabled": { section: "routing", label: "Dynamic Routing", type: "bool" },
@@ -171,6 +209,10 @@ const registry = {
"dynamic_routing.cross_provider": { section: "routing", label: "Cross-Provider Routing", type: "bool" },
"dynamic_routing.hooks": { section: "routing", label: "Routing Hooks", type: "bool" },
"dynamic_routing.capability_routing": { section: "routing", label: "Capability Routing", type: "bool" },
"dynamic_routing.allow_flat_rate_providers": { section: "routing", label: "Route Flat-Rate Providers", type: "bool",
hint: "Opt in to dynamic routing for flat-rate providers (#4386)." },
"modelOverrides": { section: "routing", label: "Model Capability Overrides", type: "object",
hint: "Per-model 7-D capability scores for routing (ADR-004)." },
// ─── Safety ─────────────────────────────────────────────────────────────
"safety_harness.enabled": { section: "safety", label: "Safety Harness", type: "bool" },
@@ -183,6 +225,8 @@ const registry = {
"safety_harness.auto_rollback": { section: "safety", label: "Auto Rollback", type: "bool" },
"safety_harness.timeout_scale_cap": { section: "safety", label: "Timeout Scale Cap", type: "number",
validator: numInRange(1, 100) },
"safety_harness.file_change_allowlist": { section: "safety", label: "File Change Allowlist", type: "list",
hint: "Glob patterns exempt from file-change validation." },
// ─── Verification ───────────────────────────────────────────────────────
"enhanced_verification": { section: "verification", label: "Enhanced Verification", type: "bool" },
@@ -231,6 +275,44 @@ const registry = {
"codebase.collapse_threshold": { section: "codebase", label: "Collapse Threshold", type: "number",
validator: numInRange(0, 10_000) },
// ─── UOK ────────────────────────────────────────────────────────────────
"uok.enabled": { section: "uok", label: "UOK Enabled", type: "bool" },
"uok.legacy_fallback.enabled": { section: "uok", label: "Legacy Fallback", type: "bool" },
"uok.gates.enabled": { section: "uok", label: "Gates", type: "bool" },
"uok.model_policy.enabled": { section: "uok", label: "Model Policy", type: "bool" },
"uok.execution_graph.enabled": { section: "uok", label: "Execution Graph", type: "bool" },
"uok.gitops.enabled": { section: "uok", label: "GitOps", type: "bool" },
"uok.gitops.turn_action": { section: "uok", label: "GitOps Turn Action", type: "enum",
validator: isEnum(["commit", "snapshot", "status-only"]) },
"uok.gitops.turn_push": { section: "uok", label: "GitOps Turn Push", type: "bool" },
"uok.audit_unified.enabled": { section: "uok", label: "Unified Audit", type: "bool" },
"uok.plan_v2.enabled": { section: "uok", label: "Plan v2", type: "bool" },
// ─── GitHub sync ──────────────────────────────────────────────────────────
"github.enabled": { section: "github", label: "GitHub Sync Enabled", type: "bool" },
"github.repo": { section: "github", label: "Repository", type: "text", example: "owner/repo" },
"github.project": { section: "github", label: "Project Number", type: "number" },
"github.labels": { section: "github", label: "Issue Labels", type: "list" },
"github.auto_link_commits": { section: "github", label: "Auto-Link Commits", type: "bool" },
"github.slice_prs": { section: "github", label: "Slice PRs", type: "bool" },
// ─── Workspace ──────────────────────────────────────────────────────────
"workspace.mode": { section: "workspace", label: "Workspace Mode", type: "enum",
validator: isEnum(["project", "parent"]) },
"workspace.repositories": { section: "workspace", label: "Repositories", type: "object" },
// ─── Claude Code MCP ──────────────────────────────────────────────────────
"claude_code_mcp.per_model": { section: "mcp", label: "Per-Model MCP Filters", type: "object" },
// ─── Auto supervisor ────────────────────────────────────────────────────
"auto_supervisor.model": { section: "experimental", label: "Supervisor Model", type: "text" },
"auto_supervisor.soft_timeout_minutes": { section: "experimental", label: "Supervisor Soft Timeout", type: "number",
validator: numInRange(1, 1440) },
"auto_supervisor.idle_timeout_minutes": { section: "experimental", label: "Supervisor Idle Timeout", type: "number",
validator: numInRange(1, 1440) },
"auto_supervisor.hard_timeout_minutes": { section: "experimental", label: "Supervisor Hard Timeout", type: "number",
validator: numInRange(1, 1440) },
// ─── Experimental ───────────────────────────────────────────────────────
"experimental.rtk": { section: "experimental", label: "RTK", type: "bool",
hint: "Experimental Rapid Task Kernel." },
+30
View File
@@ -0,0 +1,30 @@
import { loadPreferencesFromText } from "./preferencesCore";
import type { GSDModelsConfig, GSDPreferences } from "../types";
export interface ImportedWorkspace {
preferences?: GSDPreferences;
models?: GSDModelsConfig;
settings?: Record<string, unknown>;
preferencesFileName?: string;
modelsFileName?: string;
settingsFileName?: string;
}
export async function readPreferencesFromFile(
file: File,
): Promise<GSDPreferences> {
const text = await file.text();
return loadPreferencesFromText(text);
}
export async function readJsonConfigFromFile(
file: File,
): Promise<Record<string, unknown>> {
const text = await file.text();
if (!text.trim()) return {};
const parsed: unknown = JSON.parse(text);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${file.name}: expected a JSON object`);
}
return parsed as Record<string, unknown>;
}
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Global keyboard shortcut manager
// GSD Pi Config - Global keyboard shortcut manager
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// A tiny hook-based shortcut system. Handlers are registered by id so that
+20
View File
@@ -0,0 +1,20 @@
/** Open a single local file via the browser file picker. */
export function pickFile(accept?: string): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
if (accept) input.accept = accept;
input.style.display = "none";
input.onchange = () => {
const file = input.files?.[0] ?? null;
document.body.removeChild(input);
resolve(file);
};
input.oncancel = () => {
document.body.removeChild(input);
resolve(null);
};
document.body.appendChild(input);
input.click();
});
}
+94
View File
@@ -0,0 +1,94 @@
// GSD Pi Config - preferencesCore tests (mirrors Rust unit tests)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, expect, it } from "vitest";
import type { GSDPreferences, WorkflowMode } from "../types";
import {
buildShareablePreset,
loadPreferencesFromText,
normalizeStringyIds,
redactSensitive,
serializePreferences,
type JsonValue,
} from "./preferencesCore";
describe("loadPreferencesFromText", () => {
it("round-trips snowflake channel_id as string", () => {
const original: GSDPreferences = {
mode: "solo" as WorkflowMode,
remote_questions: {
channel_id: "1234567890123456789",
channel: "discord",
},
verification_commands: ["npm run build", "cargo test"],
};
const markdown = serializePreferences(original);
const loaded = loadPreferencesFromText(markdown);
expect(loaded).toEqual(original);
expect(loaded.remote_questions?.channel_id).toBe("1234567890123456789");
});
it("preserves quoted snowflake channel_id from YAML", () => {
const yaml = `---
mode: solo
remote_questions:
channel_id: "1234567890123456789"
channel: discord
---
`;
const loaded = loadPreferencesFromText(yaml);
expect(loaded.remote_questions?.channel_id).toBe("1234567890123456789");
});
it("coerces small numeric channel_id to string", () => {
const yaml = `---
remote_questions:
channel_id: 42
---
`;
const loaded = loadPreferencesFromText(yaml);
expect(loaded.remote_questions?.channel_id).toBe("42");
});
});
describe("redactSensitive", () => {
it("redacts sensitive string keys case-insensitively", () => {
const v = {
API_KEY: "aaa",
Token: "bbb",
SECRET: "ccc",
safe: "visible",
};
redactSensitive(v as JsonValue);
expect((v as Record<string, string>).API_KEY).toBe("<redacted>");
expect(v.Token).toBe("<redacted>");
expect(v.SECRET).toBe("<redacted>");
expect(v.safe).toBe("visible");
});
});
describe("buildShareablePreset", () => {
it("emits fenced yaml block without raw secrets", () => {
const out = buildShareablePreset({
mode: "solo",
api_key: "shouldnotleak",
} as never);
expect(out.startsWith("```yaml\n")).toBe(true);
expect(out.trimEnd().endsWith("```")).toBe(true);
expect(out).toContain("mode: solo");
expect(out).toContain("<redacted>");
expect(out).not.toContain("shouldnotleak");
});
});
describe("normalizeStringyIds", () => {
it("removes invalid channel_id shapes", () => {
const v = {
remote_questions: { channel_id: { bad: true } },
};
normalizeStringyIds(v as Record<string, JsonValue>);
expect(
(v.remote_questions as Record<string, unknown>).channel_id,
).toBeUndefined();
});
});
+125
View File
@@ -0,0 +1,125 @@
// GSD Pi Config - Preferences YAML/frontmatter (mirrors src-tauri core.rs)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type { GSDPreferences } from "../types";
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
/** Extract the YAML frontmatter body from a markdown string. */
export function parseFrontmatter(content: string): string | null {
const trimmed = content.trimStart();
if (!trimmed.startsWith("---")) return null;
const afterFirst = trimmed.slice(3);
const endPos = afterFirst.indexOf("\n---");
if (endPos !== -1) return afterFirst.slice(0, endPos);
const endWithEof = afterFirst.trimEnd();
if (endWithEof.endsWith("---")) return endWithEof.slice(0, -3);
return afterFirst;
}
/** Parse preferences markdown/text into a JSON-shaped object. */
export function loadPreferencesFromText(content: string): GSDPreferences {
const yamlStr = parseFrontmatter(content) ?? "";
if (yamlStr.trim() === "") return {};
const parsed = parseYaml(yamlStr);
if (parsed === null || parsed === undefined) return {};
if (typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("YAML parse error: root must be an object");
}
const value = parsed as Record<string, JsonValue>;
normalizeStringyIds(value);
return value as GSDPreferences;
}
/** Coerce channel_id (and similar) to strings before crossing JS number limits. */
export function normalizeStringyIds(value: Record<string, JsonValue>): void {
const remote = value.remote_questions;
if (!remote || typeof remote !== "object" || Array.isArray(remote)) return;
const remoteObj = remote as Record<string, JsonValue>;
const cid = remoteObj.channel_id;
if (cid === undefined || cid === null) return;
if (typeof cid === "string") {
remoteObj.channel_id = cid;
return;
}
if (typeof cid === "number" || typeof cid === "boolean") {
remoteObj.channel_id = String(cid);
return;
}
delete remoteObj.channel_id;
}
function isSensitiveKey(key: string): boolean {
const k = key.toLowerCase();
return (
k.includes("key") ||
k.includes("token") ||
k.includes("secret") ||
k.includes("password")
);
}
/** Redact string values under sensitive-looking keys (mirrors Rust redact_sensitive). */
export function redactSensitive(value: JsonValue): void {
if (Array.isArray(value)) {
for (const item of value) redactSensitive(item);
return;
}
if (value === null || typeof value !== "object") return;
const obj = value as Record<string, JsonValue>;
for (const [k, v] of Object.entries(obj)) {
if (isSensitiveKey(k) && typeof v === "string") {
obj[k] = "<redacted>";
continue;
}
redactSensitive(v);
}
}
/** Serialize preferences to canonical `---\n{yaml}---\n` format. */
export function serializePreferences(prefs: GSDPreferences): string {
const clone = structuredClone(prefs) as Record<string, JsonValue>;
normalizeStringyIds(clone);
const yamlStr = stringifyYaml(clone);
return `---\n${yamlStr}---\n`;
}
/** Build a shareable, redacted fenced YAML block for clipboard. */
export function buildShareablePreset(prefs: GSDPreferences): string {
const redacted = structuredClone(prefs) as Record<string, JsonValue>;
redactSensitive(redacted);
normalizeStringyIds(redacted);
const serialized = serializePreferences(redacted as GSDPreferences);
const body = serialized
.replace(/^---\n/, "")
.replace(/\n---\n$/, "")
.trimEnd();
return `\`\`\`yaml\n${body}\n\`\`\`\n`;
}
/** Regex patterns that block public preset submission. */
const SECRET_PATTERNS = [
/\bsk-[a-zA-Z0-9]{20,}\b/,
/\bBearer\s+[a-zA-Z0-9._-]+\b/i,
/\bghp_[a-zA-Z0-9]{20,}\b/,
/\bgho_[a-zA-Z0-9]{20,}\b/,
/\bxox[baprs]-[a-zA-Z0-9-]+\b/,
];
/** Return human-readable reasons if content looks like it contains secrets. */
export function scanForLeakedSecrets(text: string): string[] {
const hits: string[] = [];
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(text)) {
hits.push(`Matched pattern: ${pattern.source}`);
}
}
return hits;
}
+16 -2
View File
@@ -1,4 +1,4 @@
// GSD Setup - Workflow mode & token profile cascade presets
// GSD Pi Config - Workflow mode & token profile cascade presets
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// When a user picks "solo" / "team" as a workflow mode, or switches the
@@ -48,7 +48,7 @@ const MODE_PRESETS: Record<WorkflowMode, PresetPatch> = {
// ─── Token profile presets ──────────────────────────────────────────────────
// These phase-skip values mirror `resolveProfileDefaults` in GSD-2's
// These phase-skip values mirror `resolveProfileDefaults` in GSD Pi's
// `preferences-models.ts`. Keep them in sync — a profile flip here that
// disagrees with the runtime ends up applying a config the runtime would
// have overridden, confusing the user. Balanced and Quality both skip
@@ -89,6 +89,20 @@ const PROFILE_PRESETS: Record<TokenProfile, PresetPatch> = {
"enhanced_verification_post": true,
"enhanced_verification_strict": true,
},
"burn-max": {
"dynamic_routing.enabled": false,
"context_selection": "full",
"phases.skip_research": false,
"phases.skip_reassess": false,
"phases.skip_slice_research": false,
"phases.skip_milestone_validation": false,
"phases.reassess_after_slice": true,
"context_management.observation_masking": false,
"enhanced_verification": true,
"enhanced_verification_pre": true,
"enhanced_verification_post": true,
"enhanced_verification_strict": true,
},
};
// ─── Helpers ────────────────────────────────────────────────────────────────
+51
View File
@@ -0,0 +1,51 @@
// GSD Pi Config - Remote preset gallery (git-backed)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
export interface PresetIndexEntry {
slug: string;
title: string;
description: string;
tags: string[];
author: string;
path: string;
}
export interface PresetIndex {
presets: PresetIndexEntry[];
}
const DEFAULT_INDEX_URL =
"https://raw.githubusercontent.com/open-gsd/gsd-pi-presets/main/index.json";
const DEFAULT_RAW_BASE =
"https://raw.githubusercontent.com/open-gsd/gsd-pi-presets/main/";
export function presetsIndexUrl(): string {
return import.meta.env.VITE_PRESETS_INDEX_URL ?? DEFAULT_INDEX_URL;
}
export function presetsRawBaseUrl(): string {
return import.meta.env.VITE_PRESETS_RAW_BASE_URL ?? DEFAULT_RAW_BASE;
}
export function presetRawUrl(path: string): string {
const base = presetsRawBaseUrl().replace(/\/?$/, "/");
const rel = path.replace(/^\//, "");
return `${base}${rel}`;
}
export async function fetchPresetIndex(): Promise<PresetIndex> {
const res = await fetch(presetsIndexUrl(), { cache: "no-cache" });
if (!res.ok) throw new Error(`Failed to load preset index (${res.status})`);
return (await res.json()) as PresetIndex;
}
export async function fetchPresetMarkdown(path: string): Promise<string> {
const res = await fetch(presetRawUrl(path), { cache: "no-cache" });
if (!res.ok) throw new Error(`Failed to load preset (${res.status})`);
return res.text();
}
export const PRESETS_CONTRIBUTING_URL =
import.meta.env.VITE_PRESETS_CONTRIBUTING_URL ??
"https://github.com/open-gsd/gsd-pi-presets/blob/main/CONTRIBUTING.md";
+36
View File
@@ -0,0 +1,36 @@
// GSD Pi Config - Section visibility per platform
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { SECTION_GROUPS, type SectionId } from "../components/Sidebar";
import type { SectionGroup } from "../components/Sidebar";
/** Desktop-only: filesystem-backed skill/agent editors. */
export const WEB_HIDDEN_SECTIONS: readonly SectionId[] = [
"skills-library",
"agents-library",
] as const;
const WEB_HIDDEN = new Set<SectionId>(WEB_HIDDEN_SECTIONS);
export function isSectionVisibleOnWeb(id: SectionId): boolean {
return !WEB_HIDDEN.has(id);
}
export function visibleSectionIds(platform: "web" | "desktop" = "desktop"): SectionId[] {
const ids = SECTION_GROUPS.flatMap((g) => g.items.map((i) => i.id));
if (platform === "web") {
return ids.filter(isSectionVisibleOnWeb);
}
return ids;
}
export function filterSectionGroups(platform: "web" | "desktop" = "desktop"): readonly SectionGroup[] {
if (platform === "desktop") {
return SECTION_GROUPS;
}
return SECTION_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => isSectionVisibleOnWeb(item.id)),
})).filter((group) => group.items.length > 0);
}
+26
View File
@@ -0,0 +1,26 @@
// GSD Pi Config - localStorage key migration from prior app IDs
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
const MIGRATIONS: [from: string, to: string][] = [
["gsd2-config.theme", "gsd-pi-config.theme"],
["gsd2-config.recent-projects", "gsd-pi-config.recent-projects"],
["gsd2-config.last-scope", "gsd-pi-config.last-scope"],
["gsd2-config.last-project", "gsd-pi-config.last-project"],
];
/** Copy values from legacy localStorage keys once, then remove the old keys. */
export function migrateLegacyStorageKeys(): void {
try {
for (const [from, to] of MIGRATIONS) {
const value = localStorage.getItem(from);
if (value !== null && localStorage.getItem(to) === null) {
localStorage.setItem(to, value);
}
if (value !== null) {
localStorage.removeItem(from);
}
}
} catch {
// ignore (private browsing, quota, etc.)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Tauri window event listener helpers
// GSD Pi Config - Tauri window event listener helpers
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Wraps Tauri v2 window event subscriptions so the React component that
+2 -2
View File
@@ -1,4 +1,4 @@
// GSD2 Config - Theme preference (system / dark / light)
// GSD Pi Config - Theme preference (system / dark / light)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Theme is a UI-only preference that persists in localStorage, NOT in the
@@ -10,7 +10,7 @@ import { useEffect, useState } from "react";
export type ThemePreference = "system" | "dark" | "light";
export type EffectiveTheme = "dark" | "light";
const STORAGE_KEY = "gsd2-config.theme";
const STORAGE_KEY = "gsd-pi-config.theme";
/** Read the saved preference, defaulting to "system". */
export function getStoredTheme(): ThemePreference {
+51
View File
@@ -0,0 +1,51 @@
// GSD Pi Config - Shared UI class names (see index.css for definitions)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
/** Secondary action — 40px hit area, press scale, border. */
export const btn = "gsd-btn";
/** Primary action — accent fill. */
export const btnPrimary = "gsd-btn gsd-btn-primary";
/** Inactive segment in a segmented control. */
export const btnSegment = "gsd-btn-segment";
/** Active segment in a segmented control. */
export const btnSegmentActive = "gsd-btn-segment gsd-btn-segment-active";
/** Modal / palette elevated panel. */
export const modalPanel = "gsd-modal-panel";
/** Page/section title — balanced line breaks. */
export const heading = "gsd-heading";
/** Body copy — pretty wrapping, fewer orphans. */
export const prose = "gsd-prose";
/** Logo or raster with theme-aware 1px outline. */
export const imgOutline = "gsd-img-outline";
export const card = "gsd-card";
export const choiceBtn = "gsd-choice-btn";
export const choiceBtnActive = "gsd-choice-btn gsd-choice-btn-active";
/** Settings field control column (full width on narrow viewports). */
export const fieldControl = "gsd-field-control";
/** Inline error / status banner. */
export const bannerDanger =
"rounded-lg border border-gsd-danger/30 bg-gsd-danger/10 px-4 py-2.5 text-sm text-gsd-danger";
/** Muted inline banner (web workspace notice, etc.). */
export const bannerMuted =
"border-b border-gsd-border bg-gsd-surface px-4 py-2.5 text-xs leading-relaxed text-gsd-text-dim sm:px-6";
/** Wrapper for segmented controls (scope, theme, filters). */
export const segmentGroup =
"flex shrink-0 overflow-hidden rounded-md border border-gsd-border";
/** Destructive secondary action (delete, remove). */
export const btnDanger =
"gsd-btn border-gsd-border text-gsd-text-dim hover:border-gsd-danger hover:text-gsd-danger";
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD2 Config - Tauri auto-update helper
// GSD Pi Config - Tauri auto-update helper
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Thin wrapper around @tauri-apps/plugin-updater. The check() call hits the
+1 -1
View File
@@ -1,4 +1,4 @@
// GSD Setup - Pure-TS field validators
// GSD Pi Config - Pure-TS field validators
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Each validator returns `null` if the value is valid, or an error string
+15 -9
View File
@@ -1,18 +1,24 @@
// GSD Setup - Application Entry Point
// GSD Pi Config - Application Entry Point
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { migrateLegacyStorageKeys } from "./lib/storageMigration";
import { bootstrapTheme } from "./lib/theme";
// Apply the stored theme before React mounts so light-theme users don't
// see a dark flash on first paint.
migrateLegacyStorageKeys();
bootstrapTheme();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
const loadApp = () =>
import.meta.env.VITE_PLATFORM === "web"
? import("./App.web")
: import("./App.desktop");
void loadApp().then(({ default: App }) => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
});
+217
View File
@@ -0,0 +1,217 @@
// GSD Pi Config - Preset gallery (web)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ShareModal } from "../components/ShareModal";
import { WebShell } from "../components/WebShell";
import { buildShareablePreset, loadPreferencesFromText } from "../lib/preferencesCore";
import {
fetchPresetIndex,
fetchPresetMarkdown,
PRESETS_CONTRIBUTING_URL,
type PresetIndexEntry,
} from "../lib/presetsCatalog";
import { setWebDraft, writeWebDraftMeta, writeWebWorkspaceLabel } from "../platform/web";
import type { GSDPreferences } from "../types";
import { btn, btnPrimary, heading, prose } from "../lib/uiClasses";
export function GalleryPage() {
const navigate = useNavigate();
const [entries, setEntries] = useState<PresetIndexEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewContent, setPreviewContent] = useState("");
const [loadingSlug, setLoadingSlug] = useState<string | null>(null);
const loadIndex = useCallback(async () => {
setLoading(true);
setError("");
try {
const index = await fetchPresetIndex();
setEntries(index.presets ?? []);
} catch (e) {
setError(String(e));
setEntries([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadIndex();
}, [loadIndex]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
e.description.toLowerCase().includes(q) ||
e.tags.some((t) => t.toLowerCase().includes(q)) ||
e.slug.toLowerCase().includes(q),
);
}, [entries, query]);
const usePreset = async (entry: PresetIndexEntry) => {
setLoadingSlug(entry.slug);
try {
setError("");
const text = await fetchPresetMarkdown(entry.path);
const prefs = loadPreferencesFromText(text);
await setWebDraft(prefs);
writeWebDraftMeta({
title: entry.title,
description: entry.description,
sourcePresetSlug: entry.slug,
});
writeWebWorkspaceLabel(`Preset: ${entry.title}`);
navigate("/");
} catch (e) {
setError(String(e));
} finally {
setLoadingSlug(null);
}
};
const previewPreset = async (entry: PresetIndexEntry) => {
try {
setError("");
const text = await fetchPresetMarkdown(entry.path);
const prefs = loadPreferencesFromText(text) as GSDPreferences;
const content = buildShareablePreset(prefs);
setPreviewContent(content);
setPreviewOpen(true);
} catch (e) {
setError(String(e));
}
};
return (
<WebShell active="gallery">
<ShareModal
open={previewOpen}
content={previewContent}
onClose={() => setPreviewOpen(false)}
/>
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6">
<header className="mb-8">
<h1 className={`${heading} text-xl font-semibold text-gsd-text`}>Preset gallery</h1>
<p className={`${prose} text-sm text-gsd-text-dim mt-2 max-w-xl`}>
Community starting points. Open one in the editor, customize, then download files for
your machine. API keys and on-disk libraries need the{" "}
<a
href="https://github.com/open-gsd/gsd-pi-config"
className="text-gsd-accent hover:text-gsd-accent-hover"
target="_blank"
rel="noreferrer"
>
desktop app
</a>
.
</p>
<div className="flex flex-wrap gap-2 mt-4">
<Link to="/new" className={btnPrimary}>
Create new preset
</Link>
<a
href={PRESETS_CONTRIBUTING_URL}
target="_blank"
rel="noreferrer"
className={btn}
>
Submit via PR
</a>
</div>
</header>
<div className="flex gap-3 mb-6">
<input
type="search"
placeholder="Search presets…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1"
/>
<button
type="button"
onClick={() => void loadIndex()}
disabled={loading}
className={btn}
>
Refresh
</button>
</div>
{error && (
<p className="mb-4 text-sm text-gsd-danger" role="alert">
{error}
</p>
)}
{loading && <p className="text-sm text-gsd-text-dim">Loading presets</p>}
{!loading && filtered.length === 0 && !error && (
<p className="text-sm text-gsd-text-dim">
No presets found. Seed the{" "}
<a
href="https://github.com/open-gsd/gsd-pi-presets"
className="text-gsd-accent hover:text-gsd-accent-hover"
target="_blank"
rel="noreferrer"
>
gsd-pi-presets
</a>{" "}
repository or check your network.
</p>
)}
<ul className="rounded-lg border border-gsd-border bg-gsd-surface-solid/60 divide-y divide-gsd-border overflow-hidden">
{filtered.map((entry) => (
<li
key={entry.slug}
className="p-4 sm:p-5 flex flex-col sm:flex-row sm:items-start gap-4 hover:bg-gsd-surface-hover/50 transition-colors"
>
<div className="flex-1 min-w-0">
<h2 className={`${heading} text-base font-medium text-gsd-text`}>
{entry.title}
</h2>
<p className={`${prose} text-sm text-gsd-text-dim mt-1`}>{entry.description}</p>
{entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{entry.tags.map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 rounded border border-gsd-border text-gsd-text-muted"
>
{tag}
</span>
))}
</div>
)}
<p className="text-[10px] text-gsd-text-muted mt-2 font-mono">by {entry.author}</p>
</div>
<div className="flex gap-2 shrink-0">
<button
type="button"
onClick={() => void usePreset(entry)}
disabled={loadingSlug === entry.slug}
className={btnPrimary}
>
{loadingSlug === entry.slug ? "Loading…" : "Use preset"}
</button>
<button type="button" onClick={() => void previewPreset(entry)} className={btn}>
Preview
</button>
</div>
</li>
))}
</ul>
</main>
</WebShell>
);
}
+45
View File
@@ -0,0 +1,45 @@
// GSD Pi Config - GitHub OAuth callback (web)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { completeOAuthSubmit } from "../components/SubmitPresetModal";
export function OAuthCallbackPage() {
const navigate = useNavigate();
const [error, setError] = useState("");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
if (!code) {
setError("Missing authorization code");
return;
}
void (async () => {
try {
const prUrl = await completeOAuthSubmit(code);
navigate("/", { replace: true, state: { prUrl } });
} catch (e) {
setError(String(e));
}
})();
}, [navigate]);
return (
<div className="min-h-screen flex items-center justify-center bg-gsd-bg text-gsd-text p-6">
<div className="text-center max-w-md">
{error ? (
<>
<p className="text-gsd-danger text-sm">{error}</p>
<Link to="/" className="text-gsd-accent text-sm mt-4 inline-block">
Back to editor
</Link>
</>
) : (
<p className="text-sm text-gsd-text-dim">Completing sign-in</p>
)}
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
// GSD Pi Config - New preset wizard (web)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { WebShell } from "../components/WebShell";
import { applyModePreset, applyProfilePreset } from "../lib/presets";
import { setWebDraft, writeWebDraftMeta, writeWebWorkspaceLabel } from "../platform/web";
import type { TokenProfile, WorkflowMode } from "../types";
import { btn, btnPrimary, choiceBtn, choiceBtnActive, heading, prose } from "../lib/uiClasses";
export function WizardPage() {
const navigate = useNavigate();
const [mode, setMode] = useState<WorkflowMode>("solo");
const [profile, setProfile] = useState<TokenProfile>("balanced");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const create = async () => {
let prefs = applyModePreset({}, mode);
prefs = applyProfilePreset(prefs, profile);
await setWebDraft(prefs);
writeWebDraftMeta({
title: title.trim() || undefined,
description: description.trim() || undefined,
});
writeWebWorkspaceLabel(
title.trim() ? `New preset: ${title.trim()}` : "New preset (wizard)",
);
navigate("/");
};
return (
<WebShell active="new">
<main className="mx-auto w-full max-w-lg flex-1 px-4 py-8 sm:px-6 space-y-8">
<header>
<h1 className={`${heading} text-xl font-semibold text-gsd-text`}>New preset</h1>
<p className={`${prose} text-sm text-gsd-text-dim mt-2`}>
Choose workflow and token profile. You can refine every field in the editor, then
download files for GSD Pi on your machine.
</p>
</header>
<section>
<h2 className="text-xs font-semibold uppercase tracking-wider text-gsd-text-muted mb-3">
Workflow mode
</h2>
<div className="flex gap-2">
{(["solo", "team"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`flex-1 text-sm capitalize ${
mode === m ? choiceBtnActive : choiceBtn
}`}
>
{m}
</button>
))}
</div>
</section>
<section>
<h2 className="text-xs font-semibold uppercase tracking-wider text-gsd-text-muted mb-3">
Token profile
</h2>
<div className="flex flex-col gap-2">
{(["budget", "balanced", "quality"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setProfile(p)}
className={`w-full text-sm text-left capitalize ${
profile === p ? choiceBtnActive : choiceBtn
}`}
>
{p}
</button>
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-gsd-text-muted">
Optional metadata
</h2>
<input
type="text"
placeholder="Preset title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full"
/>
<textarea
placeholder="Short description for the gallery"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full"
/>
</section>
<div className="flex flex-wrap gap-2 pt-2">
<button type="button" onClick={() => void create()} className={btnPrimary}>
Open editor
</button>
<button
type="button"
onClick={() => {
void (async () => {
await setWebDraft({});
writeWebWorkspaceLabel("Blank configuration");
navigate("/");
})();
}}
className={btn}
>
Skip (blank)
</button>
</div>
</main>
</WebShell>
);
}
+97
View File
@@ -0,0 +1,97 @@
// GSD Pi Config - Platform backend (desktop Tauri vs web storage)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { createContext, useContext, type ReactNode } from "react";
import type { GSDModelsConfig, GSDPreferences } from "../types";
import type { AgentInfo } from "../components/sections/AgentsLibrarySection";
import type { SkillInfo } from "../components/sections/SkillsLibrarySection";
export interface KeyStatus {
name: string;
is_set: boolean;
preview: string | null;
}
export interface LoadAllResult {
preferences: GSDPreferences;
filePath: string;
models: GSDModelsConfig;
modelsMtime: number;
settings: Record<string, unknown>;
settingsMtime: number;
}
export interface ConfigBackend {
readonly id: "tauri" | "web";
isWeb(): boolean;
canCheckCli(): boolean;
openUrl(url: string): Promise<void>;
loadAll(projectPath?: string): Promise<LoadAllResult>;
savePreferences(prefs: GSDPreferences, projectPath?: string): Promise<void>;
saveModels(
models: GSDModelsConfig,
expectedMtimeMs: number,
projectPath?: string,
): Promise<number>;
saveSettings(
settings: Record<string, unknown>,
expectedMtimeMs: number,
projectPath?: string,
): Promise<void>;
importPresetDialog(): Promise<GSDPreferences | null>;
exportPresetDialog(prefs: GSDPreferences): Promise<void>;
buildShareablePreset(prefs: GSDPreferences): Promise<string>;
listSkills(projectPath?: string): Promise<SkillInfo[]>;
readSkill(path: string): Promise<string>;
writeSkill(path: string, content: string): Promise<void>;
createSkill(
name: string,
scope: string,
projectPath?: string,
): Promise<SkillInfo>;
deleteSkill(path: string): Promise<void>;
listAgents(projectPath?: string): Promise<AgentInfo[]>;
readAgent(path: string): Promise<string>;
writeAgent(path: string, content: string): Promise<void>;
createAgent(
name: string,
scope: string,
projectPath?: string,
): Promise<AgentInfo>;
deleteAgent(path: string): Promise<void>;
listKeyStatuses(names: string[]): Promise<KeyStatus[]>;
getKey(name: string): Promise<string | null>;
setKey(name: string, value: string): Promise<void>;
deleteKey(name: string): Promise<void>;
exportEnvFile(names: string[]): Promise<string>;
checkCliInstalled(binary: string): Promise<boolean>;
pickProjectDirectory?(): Promise<string | null>;
}
const ConfigBackendContext = createContext<ConfigBackend | null>(null);
export function ConfigBackendProvider({
backend,
children,
}: {
backend: ConfigBackend;
children: ReactNode;
}) {
return (
<ConfigBackendContext.Provider value={backend}>
{children}
</ConfigBackendContext.Provider>
);
}
export function useConfigBackend(): ConfigBackend {
const ctx = useContext(ConfigBackendContext);
if (!ctx) throw new Error("useConfigBackend requires ConfigBackendProvider");
return ctx;
}
+31
View File
@@ -0,0 +1,31 @@
// GSD Pi Config - Platform detection
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { PreferencesPlatform } from "./types";
import { tauriPresetPlatform } from "./tauri";
import { tauriBackend } from "./tauriBackend";
import { webBackend } from "./webBackend";
import { webPlatform } from "./web";
export function isWebPlatform(): boolean {
return import.meta.env.VITE_PLATFORM === "web";
}
export function getPlatform(): PreferencesPlatform {
return isWebPlatform() ? webPlatform : tauriPresetPlatform;
}
export function getConfigBackend() {
return isWebPlatform() ? webBackend : tauriBackend;
}
export type { PreferencesPlatform, LoadPreferencesResult } from "./types";
export { webPlatform, setWebDraft, readWebDraftMeta, writeWebDraftMeta } from "./web";
export {
tauriLoadAll,
tauriSavePreferences,
tauriSaveModels,
tauriSaveSettings,
tauriImportPresetDialog,
tauriExportPresetDialog,
} from "./tauri";
+215
View File
@@ -0,0 +1,215 @@
// GSD Pi Config - Tauri desktop platform
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { invoke } from "@tauri-apps/api/core";
import { open, save as saveDialog } from "@tauri-apps/plugin-dialog";
import { cleanPrefs } from "../lib/cleanPrefs";
import type { ImportedWorkspace } from "../lib/importWorkspace";
import { visibleSectionIds } from "../lib/sectionConfig";
import type { GSDModelsConfig, GSDPreferences } from "../types";
import type { LoadPreferencesResult, PreferencesPlatform } from "./types";
export interface TauriLoadExtras {
filePath: string;
models: GSDModelsConfig;
modelsMtime: number;
settings: Record<string, unknown>;
settingsMtime: number;
}
export async function tauriLoadAll(
projectPath?: string,
): Promise<TauriLoadExtras & { preferences: GSDPreferences }> {
const args = projectPath ? { projectPath } : {};
const preferences = await invoke<GSDPreferences>("load_preferences", args);
const filePath = await invoke<string>("get_preferences_path", args);
let models: GSDModelsConfig = {};
let modelsMtime = 0;
try {
const snap = await invoke<{ value: GSDModelsConfig | null; mtime_ms: number }>(
"load_models",
args,
);
models = snap.value ?? {};
modelsMtime = snap.mtime_ms ?? 0;
} catch {
models = {};
modelsMtime = 0;
}
let settings: Record<string, unknown> = {};
let settingsMtime = 0;
try {
const snap = await invoke<{
value: Record<string, unknown> | null;
mtime_ms: number;
}>("load_settings", args);
settings = snap.value ?? {};
settingsMtime = snap.mtime_ms ?? 0;
} catch {
settings = {};
settingsMtime = 0;
}
return {
preferences,
filePath,
models,
modelsMtime,
settings,
settingsMtime,
};
}
export async function tauriSavePreferences(
prefs: GSDPreferences,
projectPath?: string,
): Promise<void> {
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
const args: { preferences: unknown; projectPath?: string } = { preferences: cleaned };
if (projectPath) args.projectPath = projectPath;
await invoke("save_preferences", args);
}
export async function tauriSaveModels(
models: GSDModelsConfig,
modelsMtime: number,
projectPath?: string,
): Promise<number> {
const args: {
models: unknown;
expectedMtimeMs: number | null;
projectPath?: string;
} = {
models,
expectedMtimeMs: modelsMtime > 0 ? modelsMtime : null,
};
if (projectPath) args.projectPath = projectPath;
return invoke<number>("save_models", args);
}
export async function tauriSaveSettings(
settings: Record<string, unknown>,
settingsMtime: number,
projectPath?: string,
): Promise<number> {
const args: {
settings: unknown;
expectedMtimeMs: number | null;
projectPath?: string;
} = {
settings,
expectedMtimeMs: settingsMtime > 0 ? settingsMtime : null,
};
if (projectPath) args.projectPath = projectPath;
return invoke<number>("save_settings", args);
}
export const tauriPresetPlatform: PreferencesPlatform = {
isWeb: () => false,
visibleSections: () => visibleSectionIds(),
async loadPreferences(): Promise<LoadPreferencesResult> {
const data = await tauriLoadAll();
return {
preferences: data.preferences,
sourceLabel: data.filePath,
};
},
async savePreferences(prefs: GSDPreferences): Promise<void> {
await tauriSavePreferences(prefs);
},
async importPreset(_file: File): Promise<GSDPreferences> {
throw new Error("Use importPresetFromPath on desktop");
},
async importPresetFromText(_text: string): Promise<GSDPreferences> {
throw new Error("Use importPresetFromPath on desktop");
},
async exportPreset(_prefs: GSDPreferences, _filename?: string): Promise<void> {
throw new Error("Use exportPresetDialog on desktop");
},
async buildShareablePreset(prefs: GSDPreferences): Promise<string> {
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
return invoke<string>("build_shareable_preset", { preferences: cleaned });
},
};
export async function tauriImportPresetDialog(): Promise<GSDPreferences | null> {
const picked = await open({
title: "Import preset",
multiple: false,
directory: false,
filters: [{ name: "GSD Preset", extensions: ["preset.md", "md"] }],
});
if (typeof picked !== "string" || !picked) return null;
return invoke<GSDPreferences>("import_preset", { sourcePath: picked });
}
export async function tauriExportPresetDialog(prefs: GSDPreferences): Promise<void> {
const target = await saveDialog({
title: "Export preset",
defaultPath: "gsd.preset.md",
filters: [{ name: "GSD Preset", extensions: ["preset.md", "md"] }],
});
if (!target) return;
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
await invoke<string>("export_preset", {
targetPath: target,
preferences: cleaned,
});
}
function basename(path: string): string {
const parts = path.split(/[/\\]/);
return parts[parts.length - 1] || path;
}
export async function tauriPickPreferencesForImport(): Promise<ImportedWorkspace | null> {
const picked = await open({
title: "Select preferences.md",
multiple: false,
directory: false,
filters: [{ name: "GSD Preferences", extensions: ["md"] }],
});
if (typeof picked !== "string" || !picked) return null;
const preferences = await invoke<GSDPreferences>("import_preset", { sourcePath: picked });
return { preferences, preferencesFileName: basename(picked) };
}
export async function tauriPickModelsForImport(): Promise<
Pick<ImportedWorkspace, "models" | "modelsFileName"> | null
> {
const picked = await open({
title: "Select models.json",
multiple: false,
directory: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (typeof picked !== "string" || !picked) return null;
const value = await invoke<Record<string, unknown>>("import_json_file", {
sourcePath: picked,
});
return { models: value as GSDModelsConfig, modelsFileName: basename(picked) };
}
export async function tauriPickSettingsForImport(): Promise<
Pick<ImportedWorkspace, "settings" | "settingsFileName"> | null
> {
const picked = await open({
title: "Select settings.json",
multiple: false,
directory: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (typeof picked !== "string" || !picked) return null;
const settings = await invoke<Record<string, unknown>>("import_json_file", {
sourcePath: picked,
});
return { settings, settingsFileName: basename(picked) };
}
+159
View File
@@ -0,0 +1,159 @@
// GSD Pi Config - Tauri desktop backend
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { cleanPrefs } from "../lib/cleanPrefs";
import type { AgentInfo } from "../components/sections/AgentsLibrarySection";
import type { SkillInfo } from "../components/sections/SkillsLibrarySection";
import type { GSDModelsConfig, GSDPreferences } from "../types";
import type { ConfigBackend, KeyStatus, LoadAllResult } from "./backend";
import {
tauriExportPresetDialog,
tauriImportPresetDialog,
tauriLoadAll,
tauriSaveModels,
tauriSavePreferences,
tauriSaveSettings,
} from "./tauri";
export const tauriBackend: ConfigBackend = {
id: "tauri",
isWeb: () => false,
canCheckCli: () => true,
async openUrl(url: string): Promise<void> {
await openUrl(url);
},
async loadAll(projectPath?: string): Promise<LoadAllResult> {
return tauriLoadAll(projectPath);
},
async savePreferences(prefs: GSDPreferences, projectPath?: string): Promise<void> {
await tauriSavePreferences(prefs, projectPath);
},
async saveModels(
models: GSDModelsConfig,
expectedMtimeMs: number,
projectPath?: string,
): Promise<number> {
return tauriSaveModels(models, expectedMtimeMs, projectPath);
},
async saveSettings(
settings: Record<string, unknown>,
expectedMtimeMs: number,
projectPath?: string,
): Promise<void> {
await tauriSaveSettings(settings, expectedMtimeMs, projectPath);
},
async importPresetDialog(): Promise<GSDPreferences | null> {
return tauriImportPresetDialog();
},
async exportPresetDialog(prefs: GSDPreferences): Promise<void> {
await tauriExportPresetDialog(prefs);
},
async buildShareablePreset(prefs: GSDPreferences): Promise<string> {
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
return invoke<string>("build_shareable_preset", { preferences: cleaned });
},
async listSkills(projectPath?: string): Promise<SkillInfo[]> {
const args = projectPath ? { projectPath } : {};
return invoke<SkillInfo[]>("list_skills", args);
},
async readSkill(path: string): Promise<string> {
return invoke<string>("read_skill", { path });
},
async writeSkill(path: string, content: string): Promise<void> {
await invoke("write_skill", { path, content });
},
async createSkill(
name: string,
scope: string,
projectPath?: string,
): Promise<SkillInfo> {
const args: { name: string; scope: string; projectPath?: string } = {
name,
scope,
};
if (scope === "project" && projectPath) args.projectPath = projectPath;
return invoke<SkillInfo>("create_skill", args);
},
async deleteSkill(path: string): Promise<void> {
await invoke("delete_skill", { path });
},
async listAgents(projectPath?: string): Promise<AgentInfo[]> {
const args = projectPath ? { projectPath } : {};
return invoke<AgentInfo[]>("list_agents", args);
},
async readAgent(path: string): Promise<string> {
return invoke<string>("read_agent", { path });
},
async writeAgent(path: string, content: string): Promise<void> {
await invoke("write_agent", { path, content });
},
async createAgent(
name: string,
scope: string,
projectPath?: string,
): Promise<AgentInfo> {
const args: { name: string; scope: string; projectPath?: string } = {
name,
scope,
};
if (scope === "project" && projectPath) args.projectPath = projectPath;
return invoke<AgentInfo>("create_agent", args);
},
async deleteAgent(path: string): Promise<void> {
await invoke("delete_agent", { path });
},
async listKeyStatuses(names: string[]): Promise<KeyStatus[]> {
return invoke<KeyStatus[]>("list_key_statuses", { names });
},
async getKey(name: string): Promise<string | null> {
return invoke<string | null>("get_key", { name });
},
async setKey(name: string, value: string): Promise<void> {
await invoke("set_key", { name, value });
},
async deleteKey(name: string): Promise<void> {
await invoke("delete_key", { name });
},
async exportEnvFile(names: string[]): Promise<string> {
return invoke<string>("export_env_file", { names });
},
async checkCliInstalled(binary: string): Promise<boolean> {
return invoke<boolean>("check_cli_installed", { binary });
},
async pickProjectDirectory(): Promise<string | null> {
const selected = await open({
directory: true,
multiple: false,
title: "Select Project Folder",
});
return typeof selected === "string" && selected ? selected : null;
},
};
+22
View File
@@ -0,0 +1,22 @@
// GSD Pi Config - Platform abstraction types
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { SectionId } from "../components/Sidebar";
import type { GSDPreferences } from "../types";
export interface LoadPreferencesResult {
preferences: GSDPreferences;
/** Display path or label (desktop file path, web "Draft"). */
sourceLabel: string;
}
export interface PreferencesPlatform {
isWeb(): boolean;
visibleSections(): SectionId[];
loadPreferences(): Promise<LoadPreferencesResult>;
savePreferences(prefs: GSDPreferences): Promise<void>;
importPreset(file: File): Promise<GSDPreferences>;
importPresetFromText(text: string): Promise<GSDPreferences>;
exportPreset(prefs: GSDPreferences, filename?: string): Promise<void>;
buildShareablePreset(prefs: GSDPreferences): Promise<string>;
}
+100
View File
@@ -0,0 +1,100 @@
// GSD Pi Config - Web platform (localStorage + File API)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { cleanPrefs } from "../lib/cleanPrefs";
import {
buildShareablePreset,
loadPreferencesFromText,
serializePreferences,
} from "../lib/preferencesCore";
import { visibleSectionIds } from "../lib/sectionConfig";
import type { GSDPreferences } from "../types";
import type { LoadPreferencesResult, PreferencesPlatform } from "./types";
const META_KEY = "gsd-pi-config.web.meta";
const WORKSPACE_LABEL_KEY = "gsd-pi-config.web.workspace-label";
export interface WebDraftMeta {
title?: string;
description?: string;
sourcePresetSlug?: string;
}
export function readWebDraftMeta(): WebDraftMeta {
try {
const raw = localStorage.getItem(META_KEY);
return raw ? (JSON.parse(raw) as WebDraftMeta) : {};
} catch {
return {};
}
}
export function writeWebDraftMeta(meta: WebDraftMeta): void {
localStorage.setItem(META_KEY, JSON.stringify(meta));
}
/** Human-readable source label shown in the editor (import name, preset, new, etc.). */
export function readWebWorkspaceLabel(): string {
try {
return localStorage.getItem(WORKSPACE_LABEL_KEY) ?? "";
} catch {
return "";
}
}
export function writeWebWorkspaceLabel(label: string): void {
if (label) {
localStorage.setItem(WORKSPACE_LABEL_KEY, label);
} else {
localStorage.removeItem(WORKSPACE_LABEL_KEY);
}
}
/** Write global preferences (same store as ConfigApp web backend). */
export async function setWebDraft(prefs: GSDPreferences, meta?: WebDraftMeta): Promise<void> {
const { webBackend } = await import("./webBackend");
await webBackend.savePreferences(prefs);
if (meta) writeWebDraftMeta(meta);
}
export const webPlatform: PreferencesPlatform = {
isWeb: () => true,
visibleSections: () => visibleSectionIds("web"),
async loadPreferences(): Promise<LoadPreferencesResult> {
const { webBackend } = await import("./webBackend");
const data = await webBackend.loadAll();
return { preferences: data.preferences, sourceLabel: data.filePath };
},
async savePreferences(prefs: GSDPreferences): Promise<void> {
const { webBackend } = await import("./webBackend");
await webBackend.savePreferences(prefs);
},
async importPreset(file: File): Promise<GSDPreferences> {
const text = await file.text();
return loadPreferencesFromText(text);
},
async importPresetFromText(text: string): Promise<GSDPreferences> {
return loadPreferencesFromText(text);
},
async exportPreset(prefs: GSDPreferences, filename = "gsd.preset.md"): Promise<void> {
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
const markdown = serializePreferences(cleaned as GSDPreferences);
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename.endsWith(".preset.md") ? filename : `${filename}.preset.md`;
a.click();
URL.revokeObjectURL(url);
},
async buildShareablePreset(prefs: GSDPreferences): Promise<string> {
const cleaned = cleanPrefs(prefs as unknown as Record<string, unknown>);
return buildShareablePreset(cleaned as GSDPreferences);
},
};

Some files were not shown because too many files have changed in this diff Show More