feat(electrobun): groups, cloning, shortcuts, custom window — all 5 features

Groups Sidebar:
- SQLite groups table (4 seeded: Development, Testing, DevOps, Research)
- Left icon rail with emoji group icons, Ctrl+1-4 switching
- Active group highlighted, projects filtered by group

Project Cloning:
- Clone button on project cards (fork icon)
- git worktree add via Bun.spawn (array form, no shell strings)
- 3-clone limit, branch name validation, pending-status pattern
- Clone cards: WT badge + branch name + accent top border
- Chain link SVG icons between linked clones in grid

Keyboard Shortcuts:
- keybinding-store.svelte.ts: 16 defaults across 4 categories
- Two-scope: document capture + terminal focus guard
- KeyboardSettings.svelte: search, click-to-capture, conflict detection
- Per-binding reset + Reset All

Custom Window:
- titleBarStyle: "hidden" — no native title bar
- Vertical "AGOR" text in left sidebar (writing-mode: vertical-rl)
- Floating window controls badge (minimize/maximize/close)
- Draggable region via -webkit-app-region: drag
- Window frame persisted to SQLite (debounced 500ms)

Window is resizable by default (Electrobun BrowserWindow).
This commit is contained in:
Hibryda 2026-03-20 06:24:24 +01:00
parent 5032021915
commit a020f59cb4
14 changed files with 1741 additions and 189 deletions

View file

@ -231891,6 +231891,24 @@ CREATE TABLE IF NOT EXISTS custom_themes (
name TEXT NOT NULL, name TEXT NOT NULL,
palette TEXT NOT NULL -- JSON blob palette TEXT NOT NULL -- JSON blob
); );
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL,
position INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS keybindings (
id TEXT PRIMARY KEY,
chord TEXT NOT NULL
);
`;
var SEED_GROUPS = `
INSERT OR IGNORE INTO groups VALUES ('dev', 'Development', '\uD83D\uDD27', 0);
INSERT OR IGNORE INTO groups VALUES ('test', 'Testing', '\uD83E\uDDEA', 1);
INSERT OR IGNORE INTO groups VALUES ('ops', 'DevOps', '\uD83D\uDE80', 2);
INSERT OR IGNORE INTO groups VALUES ('research', 'Research', '\uD83D\uDD2C', 3);
`; `;
class SettingsDb { class SettingsDb {
@ -231899,6 +231917,7 @@ class SettingsDb {
mkdirSync2(CONFIG_DIR, { recursive: true }); mkdirSync2(CONFIG_DIR, { recursive: true });
this.db = new Database2(DB_PATH); this.db = new Database2(DB_PATH);
this.db.exec(SCHEMA); this.db.exec(SCHEMA);
this.db.exec(SEED_GROUPS);
const version = this.db.query("SELECT version FROM schema_version LIMIT 1").get(); const version = this.db.query("SELECT version FROM schema_version LIMIT 1").get();
if (!version) { if (!version) {
this.db.exec("INSERT INTO schema_version (version) VALUES (1)"); this.db.exec("INSERT INTO schema_version (version) VALUES (1)");
@ -231940,6 +231959,9 @@ class SettingsDb {
} }
}); });
} }
listGroups() {
return this.db.query("SELECT id, name, icon, position FROM groups ORDER BY position ASC").all();
}
getCustomThemes() { getCustomThemes() {
const rows = this.db.query("SELECT id, name, palette FROM custom_themes").all(); const rows = this.db.query("SELECT id, name, palette FROM custom_themes").all();
return rows.flatMap((r) => { return rows.flatMap((r) => {
@ -231957,6 +231979,16 @@ class SettingsDb {
deleteCustomTheme(id) { deleteCustomTheme(id) {
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id); this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
} }
getKeybindings() {
const rows = this.db.query("SELECT id, chord FROM keybindings").all();
return Object.fromEntries(rows.map((r) => [r.id, r.chord]));
}
setKeybinding(id, chord) {
this.db.query("INSERT INTO keybindings (id, chord) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET chord = excluded.chord").run(id, chord);
}
deleteKeybinding(id) {
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
}
close() { close() {
this.db.close(); this.db.close();
} }
@ -231964,6 +231996,7 @@ class SettingsDb {
var settingsDb = new SettingsDb; var settingsDb = new SettingsDb;
// src/bun/index.ts // src/bun/index.ts
import { randomUUID } from "crypto";
var DEV_SERVER_PORT = 9760; var DEV_SERVER_PORT = 9760;
var DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; var DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
var ptyClient = new PtyClient; var ptyClient = new PtyClient;
@ -231987,8 +232020,18 @@ async function connectToDaemon(retries = 5, delayMs = 500) {
} }
return false; return false;
} }
var BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
async function gitWorktreeAdd(mainRepoPath, worktreePath, branchName) {
const proc = Bun.spawn(["git", "worktree", "add", worktreePath, "-b", branchName], { cwd: mainRepoPath, stderr: "pipe", stdout: "pipe" });
const exitCode = await proc.exited;
if (exitCode !== 0) {
const errText = await new Response(proc.stderr).text();
return { ok: false, error: errText.trim() || `git exited with code ${exitCode}` };
}
return { ok: true };
}
var rpc = BrowserView.defineRPC({ var rpc = BrowserView.defineRPC({
maxRequestTime: 1e4, maxRequestTime: 15000,
handlers: { handlers: {
requests: { requests: {
"pty.create": async ({ sessionId, cols, rows, cwd }) => { "pty.create": async ({ sessionId, cols, rows, cwd }) => {
@ -232107,6 +232150,127 @@ var rpc = BrowserView.defineRPC({
console.error("[themes.deleteCustom]", err); console.error("[themes.deleteCustom]", err);
return { ok: false }; return { ok: false };
} }
},
"groups.list": () => {
try {
return { groups: settingsDb.listGroups() };
} catch (err) {
console.error("[groups.list]", err);
return { groups: [] };
}
},
"project.clone": async ({ projectId, branchName }) => {
try {
if (!BRANCH_RE.test(branchName)) {
return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." };
}
const source = settingsDb.getProject(projectId);
if (!source) {
return { ok: false, error: `Project not found: ${projectId}` };
}
const mainRepoPath = source.mainRepoPath ?? source.cwd;
const allProjects = settingsDb.listProjects();
const existingClones = allProjects.filter((p) => p.cloneOf === projectId || source.cloneOf && p.cloneOf === source.cloneOf);
if (existingClones.length >= 3) {
return { ok: false, error: "Maximum 3 clones per project reached" };
}
const cloneIndex = existingClones.length + 1;
const worktreePath = `${mainRepoPath}-wt-${cloneIndex}`;
const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName);
if (!gitResult.ok) {
return { ok: false, error: gitResult.error };
}
const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`;
const cloneConfig = {
id: cloneId,
name: `${source.name} [${branchName}]`,
cwd: worktreePath,
accent: source.accent,
provider: source.provider,
profile: source.profile,
model: source.model,
groupId: source.groupId ?? "dev",
mainRepoPath,
cloneOf: projectId,
worktreePath,
worktreeBranch: branchName,
cloneIndex
};
settingsDb.setProject(cloneId, cloneConfig);
return {
ok: true,
project: { id: cloneId, config: JSON.stringify(cloneConfig) }
};
} catch (err) {
const error2 = err instanceof Error ? err.message : String(err);
console.error("[project.clone]", err);
return { ok: false, error: error2 };
}
},
"window.minimize": () => {
try {
mainWindow.minimize();
return { ok: true };
} catch (err) {
console.error("[window.minimize]", err);
return { ok: false };
}
},
"window.maximize": () => {
try {
const frame2 = mainWindow.getFrame();
if (frame2.x <= 0 && frame2.y <= 0) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
return { ok: true };
} catch (err) {
console.error("[window.maximize]", err);
return { ok: false };
}
},
"window.close": () => {
try {
mainWindow.close();
return { ok: true };
} catch (err) {
console.error("[window.close]", err);
return { ok: false };
}
},
"window.getFrame": () => {
try {
return mainWindow.getFrame();
} catch {
return { x: 0, y: 0, width: 1400, height: 900 };
}
},
"keybindings.getAll": () => {
try {
return { keybindings: settingsDb.getKeybindings() };
} catch (err) {
console.error("[keybindings.getAll]", err);
return { keybindings: {} };
}
},
"keybindings.set": ({ id, chord }) => {
try {
settingsDb.setKeybinding(id, chord);
return { ok: true };
} catch (err) {
console.error("[keybindings.set]", err);
return { ok: false };
}
},
"keybindings.reset": ({ id }) => {
try {
settingsDb.deleteKeybinding(id);
return { ok: true };
} catch (err) {
console.error("[keybindings.reset]", err);
return { ok: false };
}
} }
}, },
messages: {} messages: {}
@ -232145,15 +232309,20 @@ async function getMainViewUrl() {
} }
connectToDaemon(); connectToDaemon();
var url = await getMainViewUrl(); var url = await getMainViewUrl();
var savedX = Number(settingsDb.getSetting("win_x") ?? 100);
var savedY = Number(settingsDb.getSetting("win_y") ?? 100);
var savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400);
var savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
var mainWindow = new BrowserWindow({ var mainWindow = new BrowserWindow({
title: "Agent Orchestrator \u2014 Electrobun", title: "Agent Orchestrator",
titleBarStyle: "hidden",
url, url,
rpc, rpc,
frame: { frame: {
width: 1400, width: isNaN(savedWidth) ? 1400 : savedWidth,
height: 900, height: isNaN(savedHeight) ? 900 : savedHeight,
x: 100, x: isNaN(savedX) ? 100 : savedX,
y: 100 y: isNaN(savedY) ? 100 : savedY
} }
}); });
console.log("Agent Orchestrator (Electrobun) started!"); console.log("Agent Orchestrator (Electrobun) started!");

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte App</title> <title>Svelte App</title>
<script type="module" crossorigin src="/assets/index-CEtguZVp.js"></script> <script type="module" crossorigin src="/assets/index-CgAt0V08.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dvoh622C.css"> <link rel="stylesheet" crossorigin href="/assets/index-rAZ5LabM.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -3,14 +3,13 @@ import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "./pty-client.ts"; import { PtyClient } from "./pty-client.ts";
import { settingsDb } from "./settings-db.ts"; import { settingsDb } from "./settings-db.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { randomUUID } from "crypto";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
// ── PTY daemon client ──────────────────────────────────────────────────────── // ── PTY daemon client ────────────────────────────────────────────────────────
// Resolve daemon socket directory. agor-ptyd writes ptyd.sock and ptyd.token
// into $XDG_RUNTIME_DIR/agor or ~/.local/share/agor/run.
const ptyClient = new PtyClient(); const ptyClient = new PtyClient();
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> { async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
@ -34,19 +33,27 @@ async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
return false; return false;
} }
// ── Clone helpers ────────────────────────────────────────────────────────────
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
async function gitWorktreeAdd(mainRepoPath: string, worktreePath: string, branchName: string): Promise<{ ok: boolean; error?: string }> {
const proc = Bun.spawn(
["git", "worktree", "add", worktreePath, "-b", branchName],
{ cwd: mainRepoPath, stderr: "pipe", stdout: "pipe" }
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
const errText = await new Response(proc.stderr).text();
return { ok: false, error: errText.trim() || `git exited with code ${exitCode}` };
}
return { ok: true };
}
// ── RPC definition ──────────────────────────────────────────────────────────── // ── RPC definition ────────────────────────────────────────────────────────────
/**
* BrowserView.defineRPC defines handlers that the WebView can call.
* The schema type parameter describes what the Bun side handles (requests from
* WebView) and what messages Bun sends to the WebView.
*
* Pattern: handlers.requests = WebViewBun calls
* handlers.messages = BunWebView fire-and-forget
* To push a message to the WebView: rpc.send["pty.output"](payload)
*/
const rpc = BrowserView.defineRPC<PtyRPCSchema>({ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
maxRequestTime: 10_000, maxRequestTime: 15_000,
handlers: { handlers: {
requests: { requests: {
"pty.create": async ({ sessionId, cols, rows, cwd }) => { "pty.create": async ({ sessionId, cols, rows, cwd }) => {
@ -55,8 +62,6 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
} }
try { try {
ptyClient.createSession({ id: sessionId, cols, rows, cwd }); ptyClient.createSession({ id: sessionId, cols, rows, cwd });
// Don't call subscribe() — CreateSession already auto-subscribes
// the creating client and starts a fanout task.
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
@ -77,7 +82,7 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
}, },
"pty.resize": ({ sessionId, cols, rows }) => { "pty.resize": ({ sessionId, cols, rows }) => {
if (!ptyClient.isConnected) return { ok: true }; // Best-effort if (!ptyClient.isConnected) return { ok: true };
try { try {
ptyClient.resize(sessionId, cols, rows); ptyClient.resize(sessionId, cols, rows);
} catch { /* ignore */ } } catch { /* ignore */ }
@ -182,29 +187,174 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
return { ok: false }; return { ok: false };
} }
}, },
// ── Groups handlers ──────────────────────────────────────────────────
"groups.list": () => {
try {
return { groups: settingsDb.listGroups() };
} catch (err) {
console.error("[groups.list]", err);
return { groups: [] };
}
},
// ── Project clone handler ────────────────────────────────────────────
"project.clone": async ({ projectId, branchName }) => {
try {
if (!BRANCH_RE.test(branchName)) {
return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." };
}
const source = settingsDb.getProject(projectId);
if (!source) {
return { ok: false, error: `Project not found: ${projectId}` };
}
// Determine the authoritative main repo path
const mainRepoPath = source.mainRepoPath ?? source.cwd;
// Count existing clones
const allProjects = settingsDb.listProjects();
const existingClones = allProjects.filter(
(p) => p.cloneOf === projectId || (source.cloneOf && p.cloneOf === source.cloneOf)
);
if (existingClones.length >= 3) {
return { ok: false, error: "Maximum 3 clones per project reached" };
}
const cloneIndex = existingClones.length + 1;
const worktreePath = `${mainRepoPath}-wt-${cloneIndex}`;
const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName);
if (!gitResult.ok) {
return { ok: false, error: gitResult.error };
}
const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`;
const cloneConfig = {
id: cloneId,
name: `${source.name} [${branchName}]`,
cwd: worktreePath,
accent: source.accent,
provider: source.provider,
profile: source.profile,
model: source.model,
groupId: source.groupId ?? "dev",
mainRepoPath,
cloneOf: projectId,
worktreePath,
worktreeBranch: branchName,
cloneIndex,
};
settingsDb.setProject(cloneId, cloneConfig);
return {
ok: true,
project: { id: cloneId, config: JSON.stringify(cloneConfig) },
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[project.clone]", err);
return { ok: false, error };
}
},
// ── Window control handlers ──────────────────────────────────────────
"window.minimize": () => {
try {
mainWindow.minimize();
return { ok: true };
} catch (err) {
console.error("[window.minimize]", err);
return { ok: false };
}
},
"window.maximize": () => {
try {
const frame = mainWindow.getFrame();
// Heuristic: if window is already near the screen edge, unmaximize
if (frame.x <= 0 && frame.y <= 0) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
return { ok: true };
} catch (err) {
console.error("[window.maximize]", err);
return { ok: false };
}
},
"window.close": () => {
try {
mainWindow.close();
return { ok: true };
} catch (err) {
console.error("[window.close]", err);
return { ok: false };
}
},
"window.getFrame": () => {
try {
return mainWindow.getFrame();
} catch {
return { x: 0, y: 0, width: 1400, height: 900 };
}
},
// ── Keybinding handlers ──────────────────────────────────────────────
"keybindings.getAll": () => {
try {
return { keybindings: settingsDb.getKeybindings() };
} catch (err) {
console.error("[keybindings.getAll]", err);
return { keybindings: {} };
}
},
"keybindings.set": ({ id, chord }) => {
try {
settingsDb.setKeybinding(id, chord);
return { ok: true };
} catch (err) {
console.error("[keybindings.set]", err);
return { ok: false };
}
},
"keybindings.reset": ({ id }) => {
try {
settingsDb.deleteKeybinding(id);
return { ok: true };
} catch (err) {
console.error("[keybindings.reset]", err);
return { ok: false };
}
},
}, },
messages: { messages: {},
// Messages section defines what the WebView can *send* to Bun (fire-and-forget).
// We don't expect any inbound messages from the WebView in this direction.
},
}, },
}); });
// ── Forward daemon events to WebView ──────────────────────────────────────── // ── Forward daemon events to WebView ────────────────────────────────────────
// session_output: forward each chunk to the WebView as a "pty.output" message.
ptyClient.on("session_output", (msg) => { ptyClient.on("session_output", (msg) => {
if (msg.type !== "session_output") return; if (msg.type !== "session_output") return;
try { try {
// data is already base64 from the daemon — pass it straight through.
rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data });
} catch (err) { } catch (err) {
console.error("[pty.output] forward error:", err); console.error("[pty.output] forward error:", err);
} }
}); });
// session_closed: notify the WebView so it can display "[Process exited]".
ptyClient.on("session_closed", (msg) => { ptyClient.on("session_closed", (msg) => {
if (msg.type !== "session_closed") return; if (msg.type !== "session_closed") return;
try { try {
@ -237,15 +387,22 @@ connectToDaemon();
const url = await getMainViewUrl(); const url = await getMainViewUrl();
// Restore persisted window frame if available
const savedX = Number(settingsDb.getSetting("win_x") ?? 100);
const savedY = Number(settingsDb.getSetting("win_y") ?? 100);
const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400);
const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
title: "Agent Orchestrator — Electrobun", title: "Agent Orchestrator",
titleBarStyle: "hidden",
url, url,
rpc, rpc,
frame: { frame: {
width: 1400, width: isNaN(savedWidth) ? 1400 : savedWidth,
height: 900, height: isNaN(savedHeight) ? 900 : savedHeight,
x: 100, x: isNaN(savedX) ? 100 : savedX,
y: 100, y: isNaN(savedY) ? 100 : savedY,
}, },
}); });

View file

@ -39,6 +39,26 @@ CREATE TABLE IF NOT EXISTS custom_themes (
name TEXT NOT NULL, name TEXT NOT NULL,
palette TEXT NOT NULL -- JSON blob palette TEXT NOT NULL -- JSON blob
); );
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL,
position INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS keybindings (
id TEXT PRIMARY KEY,
chord TEXT NOT NULL
);
`;
// Seed default groups (idempotent via INSERT OR IGNORE)
const SEED_GROUPS = `
INSERT OR IGNORE INTO groups VALUES ('dev', 'Development', '🔧', 0);
INSERT OR IGNORE INTO groups VALUES ('test', 'Testing', '🧪', 1);
INSERT OR IGNORE INTO groups VALUES ('ops', 'DevOps', '🚀', 2);
INSERT OR IGNORE INTO groups VALUES ('research', 'Research', '🔬', 3);
`; `;
// ── SettingsDb class ───────────────────────────────────────────────────────── // ── SettingsDb class ─────────────────────────────────────────────────────────
@ -51,6 +71,18 @@ export interface ProjectConfig {
provider?: string; provider?: string;
profile?: string; profile?: string;
model?: string; model?: string;
/** Group this project belongs to. Defaults to 'dev'. */
groupId?: string;
/** For clones: path of the source repo (worktree parent). */
mainRepoPath?: string;
/** For clones: ID of the original project this was cloned from. */
cloneOf?: string;
/** For clones: absolute path to the git worktree. */
worktreePath?: string;
/** For clones: branch name checked out in the worktree. */
worktreeBranch?: string;
/** 1-indexed clone number within the parent (13). */
cloneIndex?: number;
[key: string]: unknown; [key: string]: unknown;
} }
@ -60,6 +92,13 @@ export interface CustomTheme {
palette: Record<string, string>; palette: Record<string, string>;
} }
export interface Group {
id: string;
name: string;
icon: string;
position: number;
}
export class SettingsDb { export class SettingsDb {
private db: Database; private db: Database;
@ -69,6 +108,7 @@ export class SettingsDb {
this.db = new Database(DB_PATH); this.db = new Database(DB_PATH);
this.db.exec(SCHEMA); this.db.exec(SCHEMA);
this.db.exec(SEED_GROUPS);
// Seed schema_version row if missing // Seed schema_version row if missing
const version = this.db const version = this.db
@ -136,6 +176,14 @@ export class SettingsDb {
}); });
} }
// ── Groups ─────────────────────────────────────────────────────────────────
listGroups(): Group[] {
return this.db
.query<Group, []>("SELECT id, name, icon, position FROM groups ORDER BY position ASC")
.all();
}
// ── Custom Themes ───────────────────────────────────────────────────────── // ── Custom Themes ─────────────────────────────────────────────────────────
getCustomThemes(): CustomTheme[] { getCustomThemes(): CustomTheme[] {
@ -166,6 +214,25 @@ export class SettingsDb {
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id); this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
} }
// ── Keybindings ───────────────────────────────────────────────────────────
getKeybindings(): Record<string, string> {
const rows = this.db
.query<{ id: string; chord: string }, []>("SELECT id, chord FROM keybindings")
.all();
return Object.fromEntries(rows.map((r) => [r.id, r.chord]));
}
setKeybinding(id: string, chord: string): void {
this.db
.query("INSERT INTO keybindings (id, chord) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET chord = excluded.chord")
.run(id, chord);
}
deleteKeybinding(id: string): void {
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
}
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
close(): void { close(): void {

View file

@ -6,6 +6,7 @@
import ToastContainer from './ToastContainer.svelte'; import ToastContainer from './ToastContainer.svelte';
import { themeStore } from './theme-store.svelte.ts'; import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts'; import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { appRpc } from './main.ts'; import { appRpc } from './main.ts';
// ── Types ───────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────
@ -32,10 +33,22 @@
model?: string; model?: string;
contextPct?: number; contextPct?: number;
burnRate?: number; burnRate?: number;
groupId?: string;
cloneOf?: string;
worktreeBranch?: string;
mainRepoPath?: string;
cloneIndex?: number;
}
interface Group {
id: string;
name: string;
icon: string;
position: number;
} }
// ── Demo data ────────────────────────────────────────────────── // ── Demo data ──────────────────────────────────────────────────
const PROJECTS: Project[] = [ let PROJECTS = $state<Project[]>([
{ {
id: 'p1', id: 'p1',
name: 'agent-orchestrator', name: 'agent-orchestrator',
@ -49,14 +62,14 @@
model: 'claude-opus-4-5', model: 'claude-opus-4-5',
contextPct: 78, contextPct: 78,
burnRate: 0.12, burnRate: 0.12,
groupId: 'dev',
mainRepoPath: '~/code/ai/agent-orchestrator',
messages: [ messages: [
{ id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' }, { id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' },
{ id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' }, { id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' },
{ id: 3, role: 'tool-call', content: 'Read("src/lib/stores/wake-scheduler.svelte.ts")' }, { id: 3, role: 'tool-call', content: 'Read("src/lib/stores/wake-scheduler.svelte.ts")' },
{ id: 4, role: 'tool-result', content: '// 312 lines\nexport type WakeStrategy = "persistent" | "on-demand" | "smart";\n...' }, { id: 4, role: 'tool-result', content: '// 312 lines\nexport type WakeStrategy = "persistent" | "on-demand" | "smart";\n...' },
{ id: 5, role: 'assistant', content: 'Found the WakeSignal enum. I\'ll add ReviewBacklog(0.6) and hook into bttask polling. Writing wake-scorer.ts update now.' }, { id: 5, role: 'assistant', content: 'Found the WakeSignal enum. I\'ll add ReviewBacklog(0.6) and hook into bttask polling. Writing wake-scorer.ts update now.' },
{ id: 6, role: 'tool-call', content: 'Edit("src/lib/utils/wake-scorer.ts", old="// signals", new="// signals\n ReviewBacklog = 0.6,")' },
{ id: 7, role: 'assistant', content: 'Done. ReviewBacklog signal added at priority 0.6 (between TaskQueuePressure 0.7 and PeriodicFloor 0.1). Run `npm test` to verify 24 scorer tests still pass.' },
], ],
}, },
{ {
@ -70,38 +83,143 @@
provider: 'claude', provider: 'claude',
model: 'claude-sonnet-4-5', model: 'claude-sonnet-4-5',
contextPct: 32, contextPct: 32,
groupId: 'dev',
messages: [ messages: [
{ id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' }, { id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' },
{ id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' }, { id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' },
{ id: 3, role: 'tool-call', content: 'Read("src/mcp/server.ts", offset=120, limit=40)' }, { id: 3, role: 'tool-call', content: 'Read("src/mcp/server.ts", offset=120, limit=40)' },
{ id: 4, role: 'tool-result', content: 'const QUERY_TIMEOUT_MS = 5000; // default\n...' }, { id: 4, role: 'tool-result', content: 'const QUERY_TIMEOUT_MS = 5000; // default\n...' },
{ id: 5, role: 'assistant', content: 'Found it. The MCP handler has a hardcoded 5s timeout but Qdrant HNSW search on 500k vectors takes 69s at ef=256. Two options:\n1. Raise timeout to 15s (quick fix)\n2. Lower ef to 128 for faster approximate search (~3s, slight recall drop)\n\nRecommend option 2 for latency-sensitive bot responses.' }, { id: 5, role: 'assistant', content: 'Found it. Raised ef_searching to 128 and timeout to 8s as safety margin.' },
{ id: 6, role: 'user', content: 'Go with option 2.' },
{ id: 7, role: 'assistant', content: 'Updated `ef_searching: 128` in qdrant-config.ts and raised timeout to 8s as safety margin. Restarted service on port 9320.' },
], ],
}, },
]; ]);
// ── Init theme + fonts on mount ──────────────────────────────── // ── Groups state ───────────────────────────────────────────────
let groups = $state<Group[]>([
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 },
{ id: 'test', name: 'Testing', icon: '🧪', position: 1 },
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
]);
let activeGroupId = $state('dev');
// ── Filtered projects for active group ────────────────────────
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
let filteredProjects = $derived(
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
);
// Group projects into: top-level cards + clone groups
// A "clone group" is a parent + its clones rendered side by side
interface ProjectRow {
type: 'standalone';
project: Project;
}
interface CloneGroupRow {
type: 'clone-group';
parent: Project;
clones: Project[];
}
type GridRow = ProjectRow | CloneGroupRow;
let gridRows = $derived((): GridRow[] => {
const standalone: GridRow[] = [];
const cloneParentIds = new Set(
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
);
for (const p of filteredProjects) {
if (p.cloneOf) continue; // Skip clones — they go into clone groups
if (cloneParentIds.has(p.id)) {
// This parent has clones
const clones = filteredProjects
.filter(c => c.cloneOf === p.id)
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
standalone.push({ type: 'clone-group', parent: p, clones });
} else {
standalone.push({ type: 'standalone', project: p });
}
}
return standalone;
});
// ── Clone count helpers ────────────────────────────────────────
function cloneCountForProject(projectId: string): number {
return PROJECTS.filter(p => p.cloneOf === projectId).length;
}
function handleClone(projectId: string) {
// In a real app: open the clone dialog and call RPC.
// Here we demonstrate the flow by calling the RPC directly with a demo branch.
const source = PROJECTS.find(p => p.id === projectId);
if (!source) return;
const branchName = `feature/clone-${Date.now()}`;
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
if (result.ok && result.project) {
const cloneConfig = JSON.parse(result.project.config) as Project;
PROJECTS = [...PROJECTS, {
...cloneConfig,
status: 'idle',
costUsd: 0,
tokens: 0,
messages: [],
}];
} else {
console.error('[clone]', result.error);
}
}).catch(console.error);
}
// ── Init on mount ──────────────────────────────────────────────
onMount(() => { onMount(() => {
themeStore.initTheme(appRpc).catch(console.error); themeStore.initTheme(appRpc).catch(console.error);
fontStore.initFonts(appRpc).catch(console.error); fontStore.initFonts(appRpc).catch(console.error);
keybindingStore.init(appRpc).catch(console.error);
// Load groups from DB
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
if (dbGroups.length > 0) groups = dbGroups;
}).catch(console.error);
// Restore active group from settings
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
if (value && groups.some(g => g.id === value)) activeGroupId = value;
}).catch(console.error);
// Register keybinding command handlers
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; });
keybindingStore.on('group1', () => setActiveGroup(groups[0]?.id));
keybindingStore.on('group2', () => setActiveGroup(groups[1]?.id));
keybindingStore.on('group3', () => setActiveGroup(groups[2]?.id));
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
keybindingStore.on('minimize', () => appRpc.request["window.minimize"]({}).catch(console.error));
// Install listener (returns cleanup fn, but onMount handles lifecycle)
const cleanup = keybindingStore.installListener();
return cleanup;
}); });
// ── Reactive state ───────────────────────────────────────────── // ── Reactive state ─────────────────────────────────────────────
let settingsOpen = $state(false); let settingsOpen = $state(false);
let paletteOpen = $state(false); let paletteOpen = $state(false);
let notifCount = $state(2); // demo unread let notifCount = $state(2);
let sessionStart = $state(Date.now()); let sessionStart = $state(Date.now());
// Blink state — JS timer, no CSS animation (0% CPU overhead) function setActiveGroup(id: string | undefined) {
if (!id) return;
activeGroupId = id;
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
}
// Blink state
let blinkVisible = $state(true); let blinkVisible = $state(true);
$effect(() => { $effect(() => {
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500); const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
return () => clearInterval(id); return () => clearInterval(id);
}); });
// Session duration string (updates every 10s) // Session duration
let sessionDuration = $state('0m'); let sessionDuration = $state('0m');
$effect(() => { $effect(() => {
function update() { function update() {
@ -113,21 +231,19 @@
return () => clearInterval(id); return () => clearInterval(id);
}); });
// ── Global keyboard shortcuts ────────────────────────────────── // Window frame persistence (debounced 500ms)
$effect(() => { let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
function onKeydown(e: KeyboardEvent) { function saveWindowFrame() {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { if (frameSaveTimer) clearTimeout(frameSaveTimer);
e.preventDefault(); frameSaveTimer = setTimeout(() => {
paletteOpen = !paletteOpen; appRpc.request["window.getFrame"]({}).then((frame) => {
} appRpc.request["settings.set"]({ key: 'win_x', value: String(frame.x) }).catch(console.error);
if ((e.ctrlKey || e.metaKey) && e.key === ',') { appRpc.request["settings.set"]({ key: 'win_y', value: String(frame.y) }).catch(console.error);
e.preventDefault(); appRpc.request["settings.set"]({ key: 'win_width', value: String(frame.width) }).catch(console.error);
settingsOpen = !settingsOpen; appRpc.request["settings.set"]({ key: 'win_height', value: String(frame.height) }).catch(console.error);
} }).catch(console.error);
} }, 500);
window.addEventListener('keydown', onKeydown); }
return () => window.removeEventListener('keydown', onKeydown);
});
// ── Status bar aggregates ────────────────────────────────────── // ── Status bar aggregates ──────────────────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length); let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
@ -135,7 +251,6 @@
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length); let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0)); let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0)); let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
// Attention queue: projects with high context or stalled
let attentionItems = $derived( let attentionItems = $derived(
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75) PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
); );
@ -147,16 +262,54 @@
function fmtCost(n: number): string { function fmtCost(n: number): string {
return `$${n.toFixed(3)}`; return `$${n.toFixed(3)}`;
} }
// ── Window control helpers ─────────────────────────────────────
function windowMinimize() {
appRpc.request["window.minimize"]({}).catch(console.error);
}
function windowMaximize() {
appRpc.request["window.maximize"]({}).catch(console.error);
}
function windowClose() {
appRpc.request["window.close"]({}).catch(console.error);
}
</script> </script>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} /> <SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} /> <CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<ToastContainer /> <ToastContainer />
<div class="app-shell"> <div
class="app-shell"
role="presentation"
onresize={saveWindowFrame}
>
<!-- Sidebar icon rail --> <!-- Sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation"> <aside class="sidebar" role="navigation" aria-label="Primary navigation">
<!-- AGOR vertical title -->
<div class="agor-title" aria-hidden="true">AGOR</div>
<!-- Group icons -->
<div class="sidebar-groups" role="list" aria-label="Project groups">
{#each groups as group, i}
<button
class="sidebar-icon group-icon"
class:active={activeGroupId === group.id}
onclick={() => setActiveGroup(group.id)}
aria-label="{group.name} (Ctrl+{i + 1})"
title="{group.name} (Ctrl+{i + 1})"
role="listitem"
>
<span class="group-emoji" aria-hidden="true">{group.icon}</span>
</button>
{/each}
</div>
<div class="sidebar-spacer"></div> <div class="sidebar-spacer"></div>
<!-- Settings gear -->
<button <button
class="sidebar-icon" class="sidebar-icon"
class:active={settingsOpen} class:active={settingsOpen}
@ -173,32 +326,121 @@
<!-- Main workspace --> <!-- Main workspace -->
<main class="workspace"> <main class="workspace">
<div class="project-grid"> <!-- Draggable title bar area with window controls -->
{#each PROJECTS as project (project.id)} <div class="title-bar" aria-label="Window title bar">
<ProjectCard <div class="title-bar-drag" aria-hidden="true">
id={project.id} <span class="title-bar-text">{activeGroup?.name ?? 'Agent Orchestrator'}</span>
name={project.name} </div>
cwd={project.cwd} <div class="window-controls" role="toolbar" aria-label="Window controls">
accent={project.accent} <button
status={project.status} class="wc-btn"
costUsd={project.costUsd} onclick={windowMinimize}
tokens={project.tokens} aria-label="Minimize window"
messages={project.messages} title="Minimize"
provider={project.provider} >─</button>
profile={project.profile} <button
model={project.model} class="wc-btn"
contextPct={project.contextPct} onclick={windowMaximize}
burnRate={project.burnRate} aria-label="Maximize window"
{blinkVisible} title="Maximize"
/> >□</button>
<button
class="wc-btn close-btn"
onclick={windowClose}
aria-label="Close window"
title="Close"
>✕</button>
</div>
</div>
<!-- Project grid -->
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
{#each gridRows() as row (row.type === 'standalone' ? row.project.id : `cg-${row.parent.id}`)}
{#if row.type === 'standalone'}
<div role="listitem">
<ProjectCard
id={row.project.id}
name={row.project.name}
cwd={row.project.cwd}
accent={row.project.accent}
status={row.project.status}
costUsd={row.project.costUsd}
tokens={row.project.tokens}
messages={row.project.messages}
provider={row.project.provider}
profile={row.project.profile}
model={row.project.model}
contextPct={row.project.contextPct}
burnRate={row.project.burnRate}
{blinkVisible}
clonesAtMax={cloneCountForProject(row.project.id) >= 3}
onClone={handleClone}
/>
</div>
{:else}
<!-- Clone group: parent + clones in a flex row spanning full width -->
<div class="clone-group-row" role="listitem" aria-label="Project group: {row.parent.name}">
<ProjectCard
id={row.parent.id}
name={row.parent.name}
cwd={row.parent.cwd}
accent={row.parent.accent}
status={row.parent.status}
costUsd={row.parent.costUsd}
tokens={row.parent.tokens}
messages={row.parent.messages}
provider={row.parent.provider}
profile={row.parent.profile}
model={row.parent.model}
contextPct={row.parent.contextPct}
burnRate={row.parent.burnRate}
{blinkVisible}
clonesAtMax={row.clones.length >= 3}
onClone={handleClone}
/>
{#each row.clones as clone (clone.id)}
<!-- Chain link icon between cards -->
<div class="chain-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
<ProjectCard
id={clone.id}
name={clone.name}
cwd={clone.cwd}
accent={clone.accent ?? row.parent.accent}
status={clone.status}
costUsd={clone.costUsd}
tokens={clone.tokens}
messages={clone.messages}
provider={clone.provider}
profile={clone.profile}
model={clone.model}
contextPct={clone.contextPct}
burnRate={clone.burnRate}
{blinkVisible}
worktreeBranch={clone.worktreeBranch}
cloneOf={clone.cloneOf}
/>
{/each}
</div>
{/if}
{/each} {/each}
{#if filteredProjects.length === 0}
<div class="empty-group" role="listitem">
<span class="empty-group-icon" aria-hidden="true">{activeGroup?.icon ?? '📁'}</span>
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
</div>
{/if}
</div> </div>
</main> </main>
</div> </div>
<!-- Status bar --> <!-- Status bar -->
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status"> <footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
<!-- Agent states -->
{#if runningCount > 0} {#if runningCount > 0}
<span class="status-segment"> <span class="status-segment">
<span class="status-dot-sm green" aria-hidden="true"></span> <span class="status-dot-sm green" aria-hidden="true"></span>
@ -221,7 +463,6 @@
</span> </span>
{/if} {/if}
<!-- Attention queue -->
{#if attentionItems.length > 0} {#if attentionItems.length > 0}
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}"> <span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
@ -234,13 +475,17 @@
<span class="status-bar-spacer"></span> <span class="status-bar-spacer"></span>
<!-- Session duration --> <!-- Group indicator -->
<span class="status-segment" title="Active group">
<span aria-hidden="true">{activeGroup?.icon}</span>
<span class="status-value">{activeGroup?.name}</span>
</span>
<span class="status-segment" title="Session duration"> <span class="status-segment" title="Session duration">
<span>session</span> <span>session</span>
<span class="status-value">{sessionDuration}</span> <span class="status-value">{sessionDuration}</span>
</span> </span>
<!-- Tokens + cost -->
<span class="status-segment" title="Total tokens used"> <span class="status-segment" title="Total tokens used">
<span>tokens</span> <span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span> <span class="status-value">{fmtTokens(totalTokens)}</span>
@ -250,7 +495,6 @@
<span class="status-value">{fmtCost(totalCost)}</span> <span class="status-value">{fmtCost(totalCost)}</span>
</span> </span>
<!-- Notification bell -->
<button <button
class="notif-btn" class="notif-btn"
onclick={() => notifCount = 0} onclick={() => notifCount = 0}
@ -265,7 +509,6 @@
{/if} {/if}
</button> </button>
<!-- Palette hint -->
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd> <kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer> </footer>
@ -280,7 +523,7 @@
overflow: hidden; overflow: hidden;
} }
/* Sidebar icon rail */ /* ── Sidebar ──────────────────────────────────────────────── */
.sidebar { .sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
flex-shrink: 0; flex-shrink: 0;
@ -289,8 +532,34 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 0.5rem 0; padding: 0.375rem 0 0.5rem;
gap: 0.25rem; gap: 0.125rem;
}
/* Vertical AGOR title */
.agor-title {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: var(--ui-font-family);
font-weight: 800;
font-size: 0.6875rem;
letter-spacing: 0.18em;
color: var(--ctp-overlay1);
padding: 0.625rem 0;
user-select: none;
flex-shrink: 0;
}
/* Group icons section */
.sidebar-groups {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
width: 100%;
padding: 0.25rem 0;
border-bottom: 1px solid var(--ctp-surface0);
margin-bottom: 0.125rem;
} }
.sidebar-spacer { flex: 1; } .sidebar-spacer { flex: 1; }
@ -308,13 +577,31 @@
justify-content: center; justify-content: center;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
padding: 0; padding: 0;
position: relative;
} }
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); } .sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-mauve); } .sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
.sidebar-icon svg { width: 1rem; height: 1rem; } .sidebar-icon svg { width: 1rem; height: 1rem; }
/* Workspace */ /* Active group: accent border-left indicator */
.group-icon.active::before {
content: '';
position: absolute;
left: -0.375rem;
top: 25%;
bottom: 25%;
width: 2px;
background: var(--ctp-mauve);
border-radius: 0 1px 1px 0;
}
.group-emoji {
font-size: 1rem;
line-height: 1;
}
/* ── Workspace ────────────────────────────────────────────── */
.workspace { .workspace {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -323,6 +610,67 @@
overflow: hidden; overflow: hidden;
} }
/* ── Title bar (custom chrome) ────────────────────────────── */
.title-bar {
height: 2rem;
background: var(--ctp-crust);
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: stretch;
flex-shrink: 0;
/* Make entire title bar draggable; window controls override this */
-webkit-app-region: drag;
user-select: none;
}
.title-bar-drag {
flex: 1;
display: flex;
align-items: center;
padding: 0 0.75rem;
}
.title-bar-text {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-overlay1);
letter-spacing: 0.02em;
pointer-events: none;
}
.window-controls {
display: flex;
align-items: stretch;
-webkit-app-region: no-drag;
flex-shrink: 0;
}
.wc-btn {
width: 2.75rem;
background: transparent;
border: none;
color: var(--ctp-overlay1);
font-size: 0.6875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s;
padding: 0;
font-family: var(--ui-font-family);
}
.wc-btn:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.close-btn:hover {
background: var(--ctp-red);
color: var(--ctp-base);
}
/* ── Project grid ─────────────────────────────────────────── */
.project-grid { .project-grid {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -331,9 +679,61 @@
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.5rem;
background: var(--ctp-crust); background: var(--ctp-crust);
overflow-y: auto;
align-content: start;
} }
/* Status bar */ .project-grid::-webkit-scrollbar { width: 0.375rem; }
.project-grid::-webkit-scrollbar-track { background: transparent; }
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
/* Clone group: spans both grid columns, flex row */
.clone-group-row {
grid-column: 1 / -1;
display: flex;
flex-direction: row;
gap: 0;
align-items: stretch;
min-height: 0;
}
/* Each ProjectCard inside a clone group gets flex: 1 */
.clone-group-row :global(.project-card) {
flex: 1;
min-width: 0;
}
/* Chain icon between linked cards */
.chain-icon {
flex-shrink: 0;
width: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-surface1);
}
.chain-icon svg {
width: 1rem;
height: 1rem;
}
/* Empty group placeholder */
.empty-group {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 0;
color: var(--ctp-overlay0);
}
.empty-group-icon { font-size: 2rem; }
.empty-group-text { font-size: 0.875rem; font-style: italic; }
/* ── Status bar ───────────────────────────────────────────── */
.status-bar { .status-bar {
height: var(--status-bar-height); height: var(--status-bar-height);
background: var(--ctp-crust); background: var(--ctp-crust);
@ -368,11 +768,9 @@
.status-value { color: var(--ctp-text); font-weight: 500; } .status-value { color: var(--ctp-text); font-weight: 500; }
.status-bar-spacer { flex: 1; } .status-bar-spacer { flex: 1; }
/* Attention badge */
.attn-badge { color: var(--ctp-yellow); } .attn-badge { color: var(--ctp-yellow); }
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); } .attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
/* Notification bell */
.notif-btn { .notif-btn {
position: relative; position: relative;
width: 1.5rem; width: 1.5rem;
@ -411,7 +809,6 @@
line-height: 1; line-height: 1;
} }
/* Palette shortcut hint */
.palette-hint { .palette-hint {
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
background: var(--ctp-surface0); background: var(--ctp-surface0);

View file

@ -29,6 +29,14 @@
contextPct?: number; contextPct?: number;
burnRate?: number; burnRate?: number;
blinkVisible?: boolean; blinkVisible?: boolean;
/** Worktree branch name — set when this is a clone card. */
worktreeBranch?: string;
/** ID of parent project — set when this is a clone card. */
cloneOf?: string;
/** Max clones reached for this project. */
clonesAtMax?: boolean;
/** Callback when user requests cloning. */
onClone?: (projectId: string) => void;
} }
let { let {
@ -46,8 +54,34 @@
contextPct = 0, contextPct = 0,
burnRate = 0, burnRate = 0,
blinkVisible = true, blinkVisible = true,
worktreeBranch,
cloneOf,
clonesAtMax = false,
onClone,
}: Props = $props(); }: Props = $props();
// ── Clone dialog state ──────────────────────────────────────────
let showCloneDialog = $state(false);
let cloneBranchName = $state('');
let cloneError = $state('');
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
function openCloneDialog() {
cloneBranchName = '';
cloneError = '';
showCloneDialog = true;
}
function submitClone() {
if (!BRANCH_RE.test(cloneBranchName)) {
cloneError = 'Use only letters, numbers, /, _, -, .';
return;
}
onClone?.(id);
showCloneDialog = false;
}
let activeTab = $state<ProjectTab>('model'); let activeTab = $state<ProjectTab>('model');
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const seedMessages = initialMessages.slice(); const seedMessages = initialMessages.slice();
@ -77,8 +111,9 @@
<article <article
class="project-card" class="project-card"
class:is-clone={!!cloneOf}
style="--accent: {accent}" style="--accent: {accent}"
aria-label="Project: {name}" aria-label="Project: {name}{cloneOf ? ' (worktree clone)' : ''}"
> >
<!-- Header --> <!-- Header -->
<header class="project-header"> <header class="project-header">
@ -94,6 +129,12 @@
<span class="project-name" title={name}>{name}</span> <span class="project-name" title={name}>{name}</span>
<span class="project-cwd" title={cwd}>{cwd}</span> <span class="project-cwd" title={cwd}>{cwd}</span>
{#if worktreeBranch}
<span class="wt-badge" title="Worktree branch: {worktreeBranch}">
WT · {worktreeBranch}
</span>
{/if}
<span class="provider-badge" title="Provider: {provider}">{provider}</span> <span class="provider-badge" title="Provider: {provider}">{provider}</span>
{#if profile} {#if profile}
@ -112,8 +153,56 @@
{#if burnRate > 0} {#if burnRate > 0}
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span> <span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
{/if} {/if}
<!-- Clone button (only on non-clone cards) -->
{#if !cloneOf && onClone}
<button
class="clone-btn"
onclick={openCloneDialog}
disabled={clonesAtMax}
title={clonesAtMax ? 'Maximum 3 clones reached' : 'Clone into git worktree'}
aria-label="Clone project into worktree"
>
<!-- Fork / branch SVG icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="6" y1="3" x2="6" y2="15"/>
<circle cx="18" cy="6" r="3"/>
<circle cx="6" cy="18" r="3"/>
<path d="M18 9a9 9 0 0 1-9 9"/>
</svg>
</button>
{/if}
</header> </header>
<!-- Clone dialog (inline, shown above tab bar) -->
{#if showCloneDialog}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="clone-dialog"
role="dialog"
aria-label="Create worktree clone"
onkeydown={(e) => { if (e.key === 'Escape') showCloneDialog = false; }}
>
<span class="clone-dialog-label">Branch name</span>
<input
class="clone-dialog-input"
type="text"
placeholder="feature/my-branch"
bind:value={cloneBranchName}
onkeydown={(e) => { if (e.key === 'Enter') submitClone(); }}
autofocus
aria-label="New branch name for worktree"
/>
{#if cloneError}
<span class="clone-dialog-error">{cloneError}</span>
{/if}
<div class="clone-dialog-actions">
<button class="clone-dialog-cancel" onclick={() => showCloneDialog = false}>Cancel</button>
<button class="clone-dialog-submit" onclick={submitClone}>Create</button>
</div>
</div>
{/if}
<!-- Project tab bar --> <!-- Project tab bar -->
<div class="tab-bar" role="tablist" aria-label="{name} tabs"> <div class="tab-bar" role="tablist" aria-label="{name} tabs">
{#each ALL_TABS as tab} {#each ALL_TABS as tab}
@ -276,6 +365,15 @@
border-radius: 0.5rem 0 0 0.5rem; border-radius: 0.5rem 0 0 0.5rem;
} }
/* Worktree clone: accent top border instead of left stripe */
.project-card.is-clone {
border-top: 2px solid var(--accent, var(--ctp-mauve));
}
.project-card.is-clone::before {
display: none;
}
/* Header */ /* Header */
.project-header { .project-header {
height: 2.5rem; height: 2.5rem;
@ -367,6 +465,131 @@
color: var(--ctp-peach); color: var(--ctp-peach);
} }
/* Worktree badge */
.wt-badge {
flex-shrink: 0;
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 20%, transparent);
color: var(--accent, var(--ctp-mauve));
border: 1px solid color-mix(in srgb, var(--accent, var(--ctp-mauve)) 40%, transparent);
}
/* Clone button */
.clone-btn {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
background: transparent;
border: none;
color: var(--ctp-overlay1);
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.12s, color 0.12s;
margin-left: auto;
}
.clone-btn:hover:not(:disabled) {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.clone-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.clone-btn svg {
width: 0.875rem;
height: 0.875rem;
}
/* Inline clone dialog */
.clone-dialog {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
flex-wrap: wrap;
}
.clone-dialog-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
flex-shrink: 0;
}
.clone-dialog-input {
flex: 1;
min-width: 8rem;
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--term-font-family);
font-size: 0.75rem;
padding: 0 0.375rem;
outline: none;
}
.clone-dialog-input:focus { border-color: var(--ctp-mauve); }
.clone-dialog-error {
width: 100%;
font-size: 0.6875rem;
color: var(--ctp-red);
}
.clone-dialog-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.clone-dialog-cancel,
.clone-dialog-submit {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: var(--ui-font-family);
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.clone-dialog-cancel {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.clone-dialog-cancel:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.clone-dialog-submit {
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
border: 1px solid var(--ctp-mauve);
color: var(--ctp-mauve);
}
.clone-dialog-submit:hover {
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
}
/* Tab bar */ /* Tab bar */
.tab-bar { .tab-bar {
height: 2rem; height: 2rem;

View file

@ -6,6 +6,7 @@
import OrchestrationSettings from './settings/OrchestrationSettings.svelte'; import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
import AdvancedSettings from './settings/AdvancedSettings.svelte'; import AdvancedSettings from './settings/AdvancedSettings.svelte';
import MarketplaceTab from './settings/MarketplaceTab.svelte'; import MarketplaceTab from './settings/MarketplaceTab.svelte';
import KeyboardSettings from './settings/KeyboardSettings.svelte';
interface Props { interface Props {
open: boolean; open: boolean;
@ -14,7 +15,7 @@
let { open, onClose }: Props = $props(); let { open, onClose }: Props = $props();
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace'; type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace' | 'keyboard';
interface Category { interface Category {
id: CategoryId; id: CategoryId;
@ -28,6 +29,7 @@
{ id: 'security', label: 'Security', icon: '🔒' }, { id: 'security', label: 'Security', icon: '🔒' },
{ id: 'projects', label: 'Projects', icon: '📁' }, { id: 'projects', label: 'Projects', icon: '📁' },
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' }, { id: 'orchestration', label: 'Orchestration', icon: '⚙' },
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
{ id: 'advanced', label: 'Advanced', icon: '🔧' }, { id: 'advanced', label: 'Advanced', icon: '🔧' },
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' }, { id: 'marketplace', label: 'Marketplace', icon: '🛒' },
]; ];
@ -94,6 +96,8 @@
<OrchestrationSettings /> <OrchestrationSettings />
{:else if activeCategory === 'advanced'} {:else if activeCategory === 'advanced'}
<AdvancedSettings /> <AdvancedSettings />
{:else if activeCategory === 'keyboard'}
<KeyboardSettings />
{:else if activeCategory === 'marketplace'} {:else if activeCategory === 'marketplace'}
<MarketplaceTab /> <MarketplaceTab />
{/if} {/if}

View file

@ -0,0 +1,168 @@
/**
* Svelte 5 rune-based keybinding store.
* Manages global keyboard shortcuts with user-customizable chords.
* Persists overrides via settings RPC (only non-default bindings saved).
*
* Usage:
* import { keybindingStore } from './keybinding-store.svelte.ts';
* await keybindingStore.init(rpc);
*
* // Register a command handler
* keybindingStore.on('palette', () => paletteOpen = true);
*
* // Install the global keydown listener (call once)
* keybindingStore.installListener();
*/
// ── Minimal RPC interface ────────────────────────────────────────────────────
interface SettingsRpc {
request: {
"keybindings.getAll"(p: Record<string, never>): Promise<{ keybindings: Record<string, string> }>;
"keybindings.set"(p: { id: string; chord: string }): Promise<{ ok: boolean }>;
"keybindings.reset"(p: { id: string }): Promise<{ ok: boolean }>;
};
}
// ── Types ────────────────────────────────────────────────────────────────────
export interface Keybinding {
id: string;
label: string;
category: "Global" | "Navigation" | "Terminal" | "Settings";
chord: string;
defaultChord: string;
}
// ── Default bindings ─────────────────────────────────────────────────────────
const DEFAULTS: Keybinding[] = [
{ id: "palette", label: "Command Palette", category: "Global", chord: "Ctrl+K", defaultChord: "Ctrl+K" },
{ id: "settings", label: "Open Settings", category: "Global", chord: "Ctrl+,", defaultChord: "Ctrl+," },
{ id: "group1", label: "Switch to Group 1", category: "Navigation", chord: "Ctrl+1", defaultChord: "Ctrl+1" },
{ id: "group2", label: "Switch to Group 2", category: "Navigation", chord: "Ctrl+2", defaultChord: "Ctrl+2" },
{ id: "group3", label: "Switch to Group 3", category: "Navigation", chord: "Ctrl+3", defaultChord: "Ctrl+3" },
{ id: "group4", label: "Switch to Group 4", category: "Navigation", chord: "Ctrl+4", defaultChord: "Ctrl+4" },
{ id: "newTerminal", label: "New Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+T", defaultChord: "Ctrl+Shift+T" },
{ id: "closeTab", label: "Close Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+W", defaultChord: "Ctrl+Shift+W" },
{ id: "nextTab", label: "Next Terminal Tab", category: "Terminal", chord: "Ctrl+]", defaultChord: "Ctrl+]" },
{ id: "prevTab", label: "Previous Terminal Tab", category: "Terminal", chord: "Ctrl+[", defaultChord: "Ctrl+[" },
{ id: "search", label: "Global Search", category: "Global", chord: "Ctrl+Shift+F", defaultChord: "Ctrl+Shift+F" },
{ id: "notifications",label: "Notification Center", category: "Global", chord: "Ctrl+Shift+N", defaultChord: "Ctrl+Shift+N" },
{ id: "minimize", label: "Minimize Window", category: "Global", chord: "Ctrl+M", defaultChord: "Ctrl+M" },
{ id: "toggleFiles", label: "Toggle Files Tab", category: "Navigation", chord: "Ctrl+Shift+E", defaultChord: "Ctrl+Shift+E" },
{ id: "toggleMemory", label: "Toggle Memory Tab", category: "Navigation", chord: "Ctrl+Shift+M", defaultChord: "Ctrl+Shift+M" },
{ id: "reload", label: "Reload App", category: "Settings", chord: "Ctrl+R", defaultChord: "Ctrl+R" },
];
// ── Chord serialisation helpers ───────────────────────────────────────────────
export function chordFromEvent(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift");
if (e.altKey) parts.push("Alt");
const key = e.key === " " ? "Space" : e.key;
// Exclude pure modifier keys
if (!["Control", "Shift", "Alt", "Meta"].includes(key)) {
parts.push(key.length === 1 ? key.toUpperCase() : key);
}
return parts.join("+");
}
function matchesChord(e: KeyboardEvent, chord: string): boolean {
return chordFromEvent(e) === chord;
}
// ── Store ────────────────────────────────────────────────────────────────────
function createKeybindingStore() {
let bindings = $state<Keybinding[]>(DEFAULTS.map((b) => ({ ...b })));
let rpc: SettingsRpc | null = null;
const handlers = new Map<string, () => void>();
let listenerInstalled = false;
/** Load persisted overrides and merge with defaults. */
async function init(rpcInstance: SettingsRpc): Promise<void> {
rpc = rpcInstance;
try {
const { keybindings: overrides } = await rpc.request["keybindings.getAll"]({});
bindings = DEFAULTS.map((b) => ({
...b,
chord: overrides[b.id] ?? b.defaultChord,
}));
} catch (err) {
console.error("[keybinding-store] Failed to load keybindings:", err);
}
}
/** Set a custom chord for a binding id. Persists to SQLite. */
function setChord(id: string, chord: string): void {
bindings = bindings.map((b) => b.id === id ? { ...b, chord } : b);
rpc?.request["keybindings.set"]({ id, chord }).catch(console.error);
}
/** Reset a binding to its default chord. Removes SQLite override. */
function resetChord(id: string): void {
const def = DEFAULTS.find((b) => b.id === id);
if (!def) return;
bindings = bindings.map((b) => b.id === id ? { ...b, chord: def.defaultChord } : b);
rpc?.request["keybindings.reset"]({ id }).catch(console.error);
}
/** Reset all bindings to defaults. */
function resetAll(): void {
for (const b of bindings) {
if (b.chord !== b.defaultChord) resetChord(b.id);
}
}
/** Register a handler for a binding id. */
function on(id: string, handler: () => void): void {
handlers.set(id, handler);
}
/** Install a global capture-phase keydown listener. Idempotent. */
function installListener(): () => void {
if (listenerInstalled) return () => {};
listenerInstalled = true;
function handleKeydown(e: KeyboardEvent): void {
// Never intercept keys when focus is inside a terminal canvas
const target = e.target as Element | null;
if (target?.closest(".terminal-container, .xterm")) return;
const chord = chordFromEvent(e);
if (!chord) return;
for (const b of bindings) {
if (b.chord === chord) {
const handler = handlers.get(b.id);
if (handler) {
e.preventDefault();
handler();
return;
}
}
}
}
document.addEventListener("keydown", handleKeydown, { capture: true });
return () => {
document.removeEventListener("keydown", handleKeydown, { capture: true });
listenerInstalled = false;
};
}
return {
get bindings() { return bindings; },
init,
setChord,
resetChord,
resetAll,
on,
installListener,
};
}
export const keybindingStore = createKeybindingStore();

View file

@ -0,0 +1,315 @@
<script lang="ts">
import { keybindingStore, chordFromEvent, type Keybinding } from '../keybinding-store.svelte.ts';
// ── State ─────────────────────────────────────────────────────
let searchQuery = $state('');
let capturingId = $state<string | null>(null);
let conflictWarning = $state<string | null>(null);
// ── Derived filtered list ──────────────────────────────────────
let filtered = $derived(
searchQuery.trim()
? keybindingStore.bindings.filter(
(b) =>
b.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.chord.toLowerCase().includes(searchQuery.toLowerCase())
)
: keybindingStore.bindings
);
// ── Category groups ────────────────────────────────────────────
let grouped = $derived(
filtered.reduce<Record<string, Keybinding[]>>((acc, b) => {
(acc[b.category] ??= []).push(b);
return acc;
}, {})
);
const CATEGORY_ORDER = ['Global', 'Navigation', 'Terminal', 'Settings'];
// ── Capture mode ──────────────────────────────────────────────
function startCapture(id: string) {
capturingId = id;
conflictWarning = null;
}
function handleCaptureKeydown(e: KeyboardEvent, id: string) {
e.preventDefault();
e.stopPropagation();
const chord = chordFromEvent(e);
if (!chord || chord === 'Escape') {
capturingId = null;
conflictWarning = null;
return;
}
// Skip bare modifier key presses (no actual key yet)
if (!chord.match(/[A-Z0-9,.\[\]\\/'`\-=; ]|F\d+|Enter|Tab|Space|Backspace|Delete|Arrow/)) {
return;
}
// Conflict check
const conflict = keybindingStore.bindings.find(
(b) => b.id !== id && b.chord === chord
);
if (conflict) {
conflictWarning = `Conflicts with "${conflict.label}"`;
} else {
conflictWarning = null;
}
keybindingStore.setChord(id, chord);
capturingId = null;
}
function resetAll() {
keybindingStore.resetAll();
conflictWarning = null;
}
function isModified(b: Keybinding): boolean {
return b.chord !== b.defaultChord;
}
</script>
<div class="kb-settings">
<!-- Toolbar -->
<div class="kb-toolbar">
<input
class="kb-search"
type="search"
placeholder="Search shortcuts…"
bind:value={searchQuery}
aria-label="Search keyboard shortcuts"
/>
<button class="kb-reset-all" onclick={resetAll} title="Reset all shortcuts to defaults">
Reset All
</button>
</div>
{#if conflictWarning}
<div class="kb-conflict-banner" role="alert">
Warning: {conflictWarning}
</div>
{/if}
<!-- Table by category -->
{#each CATEGORY_ORDER as category}
{#if grouped[category]?.length}
<div class="kb-category">
<div class="kb-category-header">{category}</div>
<div class="kb-table">
{#each grouped[category] as binding (binding.id)}
<div class="kb-row" class:modified={isModified(binding)}>
<span class="kb-label">{binding.label}</span>
<!-- Chord cell: click to capture -->
{#if capturingId === binding.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="kb-chord capturing"
tabindex="0"
role="button"
aria-label="Press new key combination for {binding.label}"
autofocus
onkeydown={(e) => handleCaptureKeydown(e, binding.id)}
onblur={() => capturingId = null}
>
Press keys…
</div>
{:else}
<button
class="kb-chord"
onclick={() => startCapture(binding.id)}
title="Click to rebind"
aria-label="Current shortcut for {binding.label}: {binding.chord}. Click to change."
>
{binding.chord}
</button>
{/if}
<!-- Reset button: only shown when modified -->
{#if isModified(binding)}
<button
class="kb-reset"
onclick={() => keybindingStore.resetChord(binding.id)}
title="Reset to {binding.defaultChord}"
aria-label="Reset {binding.label} to default"
>
{binding.defaultChord}
</button>
{:else}
<span class="kb-reset-placeholder"></span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/each}
{#if filtered.length === 0}
<p class="kb-empty">No shortcuts match "{searchQuery}"</p>
{/if}
</div>
<style>
.kb-settings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.kb-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.kb-search {
flex: 1;
height: 1.75rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.3rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding: 0 0.5rem;
outline: none;
}
.kb-search:focus { border-color: var(--ctp-mauve); }
.kb-reset-all {
padding: 0.25rem 0.625rem;
background: transparent;
border: 1px solid var(--ctp-surface1);
border-radius: 0.3rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s;
}
.kb-reset-all:hover {
border-color: var(--ctp-red);
color: var(--ctp-red);
}
.kb-conflict-banner {
padding: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
border: 1px solid var(--ctp-yellow);
border-radius: 0.3rem;
color: var(--ctp-yellow);
font-size: 0.75rem;
}
.kb-category {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kb-category-header {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ctp-overlay0);
padding: 0 0.25rem;
}
.kb-table {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.kb-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
transition: background 0.1s;
}
.kb-row:hover { background: var(--ctp-surface0); }
.kb-row.modified { background: color-mix(in srgb, var(--ctp-mauve) 6%, transparent); }
.kb-label {
font-size: 0.8125rem;
color: var(--ctp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb-chord {
padding: 0.125rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--term-font-family);
font-size: 0.75rem;
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
white-space: nowrap;
}
.kb-chord:hover:not(.capturing) {
border-color: var(--ctp-mauve);
background: var(--ctp-surface1);
}
.kb-chord.capturing {
border-color: var(--ctp-mauve);
background: color-mix(in srgb, var(--ctp-mauve) 15%, var(--ctp-surface0));
color: var(--ctp-mauve);
animation: pulse-capture 0.8s ease-in-out infinite;
outline: none;
}
@keyframes pulse-capture {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
.kb-reset {
padding: 0.125rem 0.375rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s;
}
.kb-reset:hover {
border-color: var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.kb-reset-placeholder {
width: 5rem; /* Reserve space so layout stays stable */
}
.kb-empty {
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
padding: 2rem 0;
}
</style>

View file

@ -89,6 +89,63 @@ export type PtyRPCRequests = {
params: { id: string }; params: { id: string };
response: { ok: boolean }; response: { ok: boolean };
}; };
// ── Groups RPC ─────────────────────────────────────────────────────────────
/** Return all project groups. */
"groups.list": {
params: Record<string, never>;
response: { groups: Array<{ id: string; name: string; icon: string; position: number }> };
};
// ── Project clone RPC ──────────────────────────────────────────────────────
/** Clone a project into a git worktree. branchName must match /^[a-zA-Z0-9\/_.-]+$/. */
"project.clone": {
params: { projectId: string; branchName: string };
response: { ok: boolean; project?: { id: string; config: string }; error?: string };
};
// ── Window control RPC ─────────────────────────────────────────────────────
/** Minimize the main window. */
"window.minimize": {
params: Record<string, never>;
response: { ok: boolean };
};
/** Toggle maximize/restore on the main window. */
"window.maximize": {
params: Record<string, never>;
response: { ok: boolean };
};
/** Close the main window. */
"window.close": {
params: Record<string, never>;
response: { ok: boolean };
};
/** Get current window frame (x, y, width, height). */
"window.getFrame": {
params: Record<string, never>;
response: { x: number; y: number; width: number; height: number };
};
// ── Keybindings RPC ────────────────────────────────────────────────────────
/** Return all persisted custom keybindings (overrides only). */
"keybindings.getAll": {
params: Record<string, never>;
response: { keybindings: Record<string, string> };
};
/** Persist a single keybinding override. */
"keybindings.set": {
params: { id: string; chord: string };
response: { ok: boolean };
};
/** Reset a keybinding to default (removes override). */
"keybindings.reset": {
params: { id: string };
response: { ok: boolean };
};
}; };
// ── Messages (Bun → WebView, fire-and-forget) ──────────────────────────────── // ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────