mirror of
https://github.com/BillyOutlast/gsd-pi-config.git
synced 2026-07-01 08:34:05 -04:00
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:
@@ -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
|
||||
|
||||
150–200ms 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
'',
|
||||
|
||||
@@ -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
|
||||
@@ -26,3 +26,4 @@ Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.vercel
|
||||
|
||||
@@ -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 Vercel’s 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
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+2382
-4
File diff suppressed because it is too large
Load Diff
+13
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1552,7 +1552,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gsd2-config"
|
||||
name = "gsd-pi-config"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 & 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";
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 |
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } })}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 (0–100) 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 Code–compatible 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;
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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." },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user