feat(electrobun): final 5% — full integration, real data, polish
1. Claude CLI: additionalDirectories + worktreeName passthrough 2. Agent-store: reads settings (default_cwd, provider model, permission) 3. Project hydration: SQLite replaces hardcoded PROJECTS, add/remove UI 4. Group hydration: SQLite groups, add/delete in sidebar 5. Terminal auto-spawn: reads default_cwd from settings 6. Context tab: real tokens from agent-store, file refs, turn count 7. Memory tab: Memora DB integration (read-only, graceful if missing) 8. Docs tab: markdown viewer (files.list + files.read + inline renderer) 9. SSH tab: CRUD connections, spawn PTY with ssh command 10. Error handling: global unhandledrejection → toast notifications 11. Notifications: agent done/error/stall → toasts, 15min stall timer 12. Command palette: all 18 commands (was 10) +1,198 lines, 13 files. Electrobun now 100% feature-complete vs Tauri v3.
This commit is contained in:
parent
4826b9dffa
commit
8e756d3523
13 changed files with 1199 additions and 239 deletions
|
|
@ -8,6 +8,7 @@ import { btmsgDb } from "./btmsg-db.ts";
|
|||
import { bttaskDb } from "./bttask-db.ts";
|
||||
import { SidecarManager } from "./sidecar-manager.ts";
|
||||
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SearchDb } from "./search-db.ts";
|
||||
import { checkForUpdates, getLastCheckTimestamp } from "./updater.ts";
|
||||
|
|
@ -188,6 +189,16 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
}
|
||||
},
|
||||
|
||||
"settings.deleteProject": ({ id }) => {
|
||||
try {
|
||||
settingsDb.deleteProject(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[settings.deleteProject]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Custom Themes handlers ───────────────────────────────────────────
|
||||
|
||||
"themes.getCustom": () => {
|
||||
|
|
@ -303,6 +314,26 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
}
|
||||
},
|
||||
|
||||
"groups.create": ({ id, name, icon, position }) => {
|
||||
try {
|
||||
settingsDb.createGroup(id, name, icon, position);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[groups.create]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"groups.delete": ({ id }) => {
|
||||
try {
|
||||
settingsDb.deleteGroup(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[groups.delete]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Project clone handler ────────────────────────────────────────────
|
||||
|
||||
"project.clone": async ({ projectId, branchName }) => {
|
||||
|
|
@ -456,7 +487,7 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
|
||||
// ── Agent handlers ──────────────────────────────────────────────────
|
||||
|
||||
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv }) => {
|
||||
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }) => {
|
||||
try {
|
||||
const result = sidecarManager.startSession(sessionId, provider, prompt, {
|
||||
cwd,
|
||||
|
|
@ -466,6 +497,8 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
permissionMode,
|
||||
claudeConfigDir,
|
||||
extraEnv,
|
||||
additionalDirectories,
|
||||
worktreeName,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
|
|
@ -970,6 +1003,58 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
}
|
||||
},
|
||||
|
||||
// ── Memora handlers (read-only) ─────────────────────────────────────
|
||||
|
||||
"memora.search": ({ query, limit }) => {
|
||||
try {
|
||||
const dbPath = join(homedir(), ".local", "share", "memora", "memories.db");
|
||||
if (!fs.existsSync(dbPath)) return { memories: [] };
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.query("SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ?")
|
||||
.all(`%${query}%`, limit ?? 20) as Array<{
|
||||
id: number; content: string; tags: string;
|
||||
metadata: string; createdAt: string; updatedAt: string;
|
||||
}>;
|
||||
return { memories: rows };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[memora.search]", err);
|
||||
return { memories: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"memora.list": ({ limit, tag }) => {
|
||||
try {
|
||||
const dbPath = join(homedir(), ".local", "share", "memora", "memories.db");
|
||||
if (!fs.existsSync(dbPath)) return { memories: [] };
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
let sql = "SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories";
|
||||
const params: unknown[] = [];
|
||||
if (tag) {
|
||||
sql += " WHERE tags LIKE ?";
|
||||
params.push(`%${tag}%`);
|
||||
}
|
||||
sql += " ORDER BY updated_at DESC LIMIT ?";
|
||||
params.push(limit ?? 20);
|
||||
const rows = db.query(sql).all(...params) as Array<{
|
||||
id: number; content: string; tags: string;
|
||||
metadata: string; createdAt: string; updatedAt: string;
|
||||
}>;
|
||||
return { memories: rows };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[memora.list]", err);
|
||||
return { memories: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Telemetry handler ────────────────────────────────────────────────
|
||||
|
||||
"telemetry.log": ({ level, message, attributes }) => {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,10 @@ export class SettingsDb {
|
|||
.run(id, json);
|
||||
}
|
||||
|
||||
deleteProject(id: string): void {
|
||||
this.db.query("DELETE FROM projects WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
listProjects(): ProjectConfig[] {
|
||||
const rows = this.db
|
||||
.query<{ config: string }, []>("SELECT config FROM projects")
|
||||
|
|
@ -202,6 +206,16 @@ export class SettingsDb {
|
|||
.all();
|
||||
}
|
||||
|
||||
createGroup(id: string, name: string, icon: string, position: number): void {
|
||||
this.db
|
||||
.query("INSERT INTO groups (id, name, icon, position) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, icon = excluded.icon, position = excluded.position")
|
||||
.run(id, name, icon, position);
|
||||
}
|
||||
|
||||
deleteGroup(id: string): void {
|
||||
this.db.query("DELETE FROM groups WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// ── Custom Themes ─────────────────────────────────────────────────────────
|
||||
|
||||
getCustomThemes(): CustomTheme[] {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export interface StartSessionOptions {
|
|||
permissionMode?: string;
|
||||
claudeConfigDir?: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
additionalDirectories?: string[];
|
||||
worktreeName?: string;
|
||||
}
|
||||
|
||||
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
|
||||
|
|
@ -222,7 +224,7 @@ export class SidecarManager {
|
|||
});
|
||||
|
||||
// Send the query command to the runner
|
||||
const queryMsg = {
|
||||
const queryMsg: Record<string, unknown> = {
|
||||
type: "query",
|
||||
sessionId,
|
||||
prompt,
|
||||
|
|
@ -235,6 +237,13 @@ export class SidecarManager {
|
|||
extraEnv: validateExtraEnv(options.extraEnv),
|
||||
};
|
||||
|
||||
if (options.additionalDirectories?.length) {
|
||||
queryMsg.additionalDirectories = options.additionalDirectories;
|
||||
}
|
||||
if (options.worktreeName) {
|
||||
queryMsg.worktreeName = options.worktreeName;
|
||||
}
|
||||
|
||||
this.writeToProcess(sessionId, queryMsg);
|
||||
|
||||
return { ok: true };
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { fontStore } from './font-store.svelte.ts';
|
||||
import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||
import { trackProject } from './health-store.svelte.ts';
|
||||
import { setAgentToastFn } from './agent-store.svelte.ts';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
|
@ -53,60 +54,81 @@
|
|||
hasNew?: boolean;
|
||||
}
|
||||
|
||||
// ── Demo data ──────────────────────────────────────────────────
|
||||
let PROJECTS = $state<Project[]>([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'agent-orchestrator',
|
||||
cwd: '~/code/ai/agent-orchestrator',
|
||||
accent: 'var(--ctp-mauve)',
|
||||
status: 'running',
|
||||
costUsd: 0.034,
|
||||
tokens: 18420,
|
||||
provider: 'claude',
|
||||
profile: 'dev',
|
||||
model: 'claude-opus-4-5',
|
||||
contextPct: 78,
|
||||
burnRate: 0.12,
|
||||
groupId: 'dev',
|
||||
mainRepoPath: '~/code/ai/agent-orchestrator',
|
||||
messages: [
|
||||
{ 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: 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: 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: 'p2',
|
||||
name: 'quanta-discord-bot',
|
||||
cwd: '~/code/quanta/discord-bot',
|
||||
accent: 'var(--ctp-sapphire)',
|
||||
status: 'idle',
|
||||
costUsd: 0.011,
|
||||
tokens: 6830,
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
contextPct: 32,
|
||||
groupId: 'dev',
|
||||
messages: [
|
||||
{ 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: 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: 5, role: 'assistant', content: 'Found it. Raised ef_searching to 128 and timeout to 8s as safety margin.' },
|
||||
],
|
||||
},
|
||||
// ── Accent colors for auto-assignment ─────────────────────────
|
||||
const ACCENTS = [
|
||||
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
|
||||
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
|
||||
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
|
||||
];
|
||||
|
||||
// ── Projects state (loaded from SQLite) ─────────────────────────
|
||||
let PROJECTS = $state<Project[]>([]);
|
||||
|
||||
// ── Groups state (loaded from SQLite) ────────────────────────────
|
||||
let groups = $state<Group[]>([
|
||||
{ id: 'dev', name: 'Development', icon: '1', position: 0 },
|
||||
]);
|
||||
|
||||
// ── Groups state ───────────────────────────────────────────────
|
||||
let groups = $state<Group[]>([
|
||||
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 },
|
||||
{ id: 'test', name: 'Testing', icon: '🧪', position: 1, hasNew: true },
|
||||
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
|
||||
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
|
||||
]);
|
||||
// ── Add/Remove project UI state ──────────────────────────────────
|
||||
let showAddProject = $state(false);
|
||||
let newProjectName = $state('');
|
||||
let newProjectCwd = $state('');
|
||||
let projectToDelete = $state<string | null>(null);
|
||||
|
||||
async function addProject() {
|
||||
const name = newProjectName.trim();
|
||||
const cwd = newProjectCwd.trim();
|
||||
if (!name || !cwd) return;
|
||||
|
||||
const id = `p-${Date.now()}`;
|
||||
const accent = ACCENTS[PROJECTS.length % ACCENTS.length];
|
||||
const project: Project = {
|
||||
id, name, cwd, accent,
|
||||
status: 'idle', costUsd: 0, tokens: 0, messages: [],
|
||||
provider: 'claude', groupId: activeGroupId,
|
||||
};
|
||||
PROJECTS = [...PROJECTS, project];
|
||||
trackProject(id);
|
||||
|
||||
await appRpc.request['settings.setProject']({
|
||||
id,
|
||||
config: JSON.stringify(project),
|
||||
}).catch(console.error);
|
||||
|
||||
showAddProject = false;
|
||||
newProjectName = '';
|
||||
newProjectCwd = '';
|
||||
}
|
||||
|
||||
async function confirmDeleteProject() {
|
||||
if (!projectToDelete) return;
|
||||
PROJECTS = PROJECTS.filter(p => p.id !== projectToDelete);
|
||||
await appRpc.request['settings.deleteProject']({ id: projectToDelete }).catch(console.error);
|
||||
projectToDelete = null;
|
||||
}
|
||||
|
||||
// ── Add/Remove group UI state ───────────────────────────────────
|
||||
let showAddGroup = $state(false);
|
||||
let newGroupName = $state('');
|
||||
|
||||
async function addGroup() {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
const id = `grp-${Date.now()}`;
|
||||
const position = groups.length;
|
||||
const group: Group = { id, name, icon: String(position + 1), position };
|
||||
groups = [...groups, group];
|
||||
await appRpc.request['groups.create']({ id, name, icon: group.icon, position }).catch(console.error);
|
||||
showAddGroup = false;
|
||||
newGroupName = '';
|
||||
}
|
||||
|
||||
async function removeGroup(id: string) {
|
||||
if (groups.length <= 1) return; // keep at least one group
|
||||
groups = groups.filter(g => g.id !== id);
|
||||
if (activeGroupId === id) activeGroupId = groups[0]?.id ?? 'dev';
|
||||
await appRpc.request['groups.delete']({ id }).catch(console.error);
|
||||
}
|
||||
let activeGroupId = $state('dev');
|
||||
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
|
||||
let previousGroupId = $state<string | null>(null);
|
||||
|
|
@ -278,23 +300,64 @@
|
|||
};
|
||||
});
|
||||
|
||||
// ── Toast ref for agent notifications ─────────────────────────
|
||||
let toastRef: ToastContainer | undefined;
|
||||
|
||||
function showToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
|
||||
toastRef?.addToast(message, variant);
|
||||
}
|
||||
|
||||
// ── Global error boundary ──────────────────────────────────────
|
||||
function setupErrorBoundary() {
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason);
|
||||
console.error('[unhandled rejection]', e.reason);
|
||||
showToast(`Unhandled error: ${msg.slice(0, 100)}`, 'error');
|
||||
e.preventDefault();
|
||||
});
|
||||
window.addEventListener('error', (e: ErrorEvent) => {
|
||||
console.error('[uncaught error]', e.error);
|
||||
showToast(`Error: ${e.message.slice(0, 100)}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
// Wire agent toast callback
|
||||
setAgentToastFn(showToast);
|
||||
|
||||
// Set up global error boundary
|
||||
setupErrorBoundary();
|
||||
|
||||
// Run all init tasks in parallel, mark app ready when all complete
|
||||
const initTasks = [
|
||||
themeStore.initTheme(appRpc).catch(console.error),
|
||||
fontStore.initFonts(appRpc).catch(console.error),
|
||||
keybindingStore.init(appRpc).catch(console.error),
|
||||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
|
||||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => {
|
||||
if (dbGroups.length > 0) groups = dbGroups;
|
||||
}).catch(console.error),
|
||||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
|
||||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }: { value: string | null }) => {
|
||||
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
||||
}).catch(console.error),
|
||||
// Load projects from SQLite
|
||||
appRpc.request["settings.getProjects"]({}).then(({ projects }: { projects: Array<{ id: string; config: string }> }) => {
|
||||
if (projects.length > 0) {
|
||||
const loaded: Project[] = projects.flatMap(({ config }) => {
|
||||
try {
|
||||
const p = JSON.parse(config) as Project;
|
||||
return [{ ...p, status: p.status ?? 'idle', costUsd: p.costUsd ?? 0, tokens: p.tokens ?? 0, messages: p.messages ?? [] }];
|
||||
} catch { return []; }
|
||||
});
|
||||
if (loaded.length > 0) PROJECTS = loaded;
|
||||
}
|
||||
}).catch(console.error),
|
||||
];
|
||||
|
||||
Promise.allSettled(initTasks).then(() => {
|
||||
appReady = true;
|
||||
// Track projects for health monitoring after load
|
||||
for (const p of PROJECTS) trackProject(p.id);
|
||||
});
|
||||
|
||||
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
|
||||
|
|
@ -314,9 +377,6 @@
|
|||
}
|
||||
document.addEventListener('keydown', handleSearchShortcut);
|
||||
|
||||
// Track projects for health monitoring
|
||||
for (const p of PROJECTS) trackProject(p.id);
|
||||
|
||||
const cleanup = keybindingStore.installListener();
|
||||
return () => {
|
||||
cleanup();
|
||||
|
|
@ -329,7 +389,7 @@
|
|||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
|
||||
<ToastContainer />
|
||||
<ToastContainer bind:this={toastRef} />
|
||||
<NotifDrawer
|
||||
open={drawerOpen}
|
||||
{notifications}
|
||||
|
|
@ -363,8 +423,43 @@
|
|||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Add group button -->
|
||||
<button
|
||||
class="group-btn add-group-btn"
|
||||
onclick={() => showAddGroup = !showAddGroup}
|
||||
aria-label="Add group"
|
||||
title="Add group"
|
||||
>
|
||||
<span class="group-circle" aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddGroup}
|
||||
<div class="add-group-form">
|
||||
<input
|
||||
class="add-group-input"
|
||||
type="text"
|
||||
placeholder="Group name"
|
||||
bind:value={newGroupName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addGroup(); if (e.key === 'Escape') showAddGroup = false; }}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add project button -->
|
||||
<button
|
||||
class="sidebar-icon"
|
||||
onclick={() => showAddProject = !showAddProject}
|
||||
aria-label="Add project"
|
||||
title="Add project"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
|
||||
<!-- Settings gear -->
|
||||
|
|
@ -416,6 +511,41 @@
|
|||
style:display={filteredProjects.length === 0 ? 'flex' : 'none'}>
|
||||
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
|
||||
</div>
|
||||
|
||||
<!-- Add project card -->
|
||||
<div class="add-card" role="listitem" style:display={showAddProject ? 'flex' : 'none'}>
|
||||
<div class="add-card-form">
|
||||
<input
|
||||
class="add-input"
|
||||
type="text"
|
||||
placeholder="Project name"
|
||||
bind:value={newProjectName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
|
||||
/>
|
||||
<input
|
||||
class="add-input"
|
||||
type="text"
|
||||
placeholder="Working directory (e.g. ~/code/myproject)"
|
||||
bind:value={newProjectCwd}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
|
||||
/>
|
||||
<div class="add-card-actions">
|
||||
<button class="add-cancel" onclick={() => showAddProject = false}>Cancel</button>
|
||||
<button class="add-confirm" onclick={addProject}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete project confirmation -->
|
||||
{#if projectToDelete}
|
||||
<div class="delete-overlay" role="listitem">
|
||||
<p class="delete-text">Delete project "{PROJECTS.find(p => p.id === projectToDelete)?.name}"?</p>
|
||||
<div class="add-card-actions">
|
||||
<button class="add-cancel" onclick={() => projectToDelete = null}>Cancel</button>
|
||||
<button class="delete-confirm" onclick={confirmDeleteProject}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -706,5 +836,79 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Add group form ─────────────────────────────────────────── */
|
||||
.add-group-form {
|
||||
padding: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-group-input {
|
||||
width: 100%;
|
||||
padding: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--ui-font-family);
|
||||
text-align: center;
|
||||
}
|
||||
.add-group-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
|
||||
/* ── Add project card ──────────────────────────────────────── */
|
||||
.add-card {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: column;
|
||||
background: var(--ctp-base);
|
||||
border: 1px dashed var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.add-card-form { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
|
||||
.add-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
.add-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.add-input::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.add-card-actions { display: flex; gap: 0.375rem; justify-content: flex-end; }
|
||||
|
||||
.add-cancel, .add-confirm, .delete-confirm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.add-cancel { background: transparent; border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); }
|
||||
.add-cancel:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.add-confirm { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border: 1px solid var(--ctp-green); color: var(--ctp-green); }
|
||||
.add-confirm:hover { background: color-mix(in srgb, var(--ctp-green) 35%, transparent); }
|
||||
|
||||
.delete-overlay {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-red);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-text { font-size: 0.875rem; color: var(--ctp-text); margin: 0; }
|
||||
.delete-confirm { background: color-mix(in srgb, var(--ctp-red) 20%, transparent); border: 1px solid var(--ctp-red); color: var(--ctp-red); }
|
||||
.delete-confirm:hover { background: color-mix(in srgb, var(--ctp-red) 35%, transparent); }
|
||||
|
||||
/* Status bar styles are in StatusBar.svelte */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -16,17 +16,30 @@
|
|||
action: () => void;
|
||||
}
|
||||
|
||||
// Build commands — actions dispatch via CustomEvent so App.svelte can handle
|
||||
function dispatch(name: string) {
|
||||
window.dispatchEvent(new CustomEvent('palette-command', { detail: name }));
|
||||
}
|
||||
|
||||
const COMMANDS: Command[] = [
|
||||
{ id: 'new-terminal', label: 'New Terminal Tab', shortcut: 'Ctrl+`', action: () => {} },
|
||||
{ id: 'settings', label: 'Open Settings', shortcut: 'Ctrl+,', action: () => {} },
|
||||
{ id: 'search', label: 'Search Messages', shortcut: 'Ctrl+F', action: () => {} },
|
||||
{ id: 'new-project', label: 'Add Project', description: 'Open a project directory', action: () => {} },
|
||||
{ id: 'clear-agent', label: 'Clear Agent Context', description: 'Reset agent session', action: () => {} },
|
||||
{ id: 'copy-cost', label: 'Copy Session Cost', action: () => {} },
|
||||
{ id: 'docs', label: 'Open Documentation', shortcut: 'F1', action: () => {} },
|
||||
{ id: 'theme', label: 'Change Theme', description: 'Currently: Catppuccin Mocha', action: () => {} },
|
||||
{ id: 'split-h', label: 'Split Horizontally', shortcut: 'Ctrl+\\', action: () => {} },
|
||||
{ id: 'split-v', label: 'Split Vertically', shortcut: 'Ctrl+Shift+\\', action: () => {} },
|
||||
{ id: 'new-terminal', label: 'New Terminal Tab', shortcut: 'Ctrl+`', action: () => dispatch('new-terminal') },
|
||||
{ id: 'settings', label: 'Open Settings', shortcut: 'Ctrl+,', action: () => dispatch('settings') },
|
||||
{ id: 'search', label: 'Search Messages', shortcut: 'Ctrl+Shift+F', action: () => dispatch('search') },
|
||||
{ id: 'new-project', label: 'Add Project', description: 'Open a project directory', action: () => dispatch('new-project') },
|
||||
{ id: 'clear-agent', label: 'Clear Agent Context', description: 'Reset agent session', action: () => dispatch('clear-agent') },
|
||||
{ id: 'copy-cost', label: 'Copy Session Cost', action: () => dispatch('copy-cost') },
|
||||
{ id: 'docs', label: 'Open Documentation', shortcut: 'F1', action: () => dispatch('docs') },
|
||||
{ id: 'theme', label: 'Change Theme', description: 'Switch between 17 themes', action: () => dispatch('theme') },
|
||||
{ id: 'split-h', label: 'Split Horizontally', shortcut: 'Ctrl+\\', action: () => dispatch('split-h') },
|
||||
{ id: 'split-v', label: 'Split Vertically', shortcut: 'Ctrl+Shift+\\', action: () => dispatch('split-v') },
|
||||
{ id: 'focus-next', label: 'Focus Next Project', shortcut: 'Ctrl+Tab', action: () => dispatch('focus-next') },
|
||||
{ id: 'focus-prev', label: 'Focus Previous Project', shortcut: 'Ctrl+Shift+Tab', action: () => dispatch('focus-prev') },
|
||||
{ id: 'close-tab', label: 'Close Tab', shortcut: 'Ctrl+W', action: () => dispatch('close-tab') },
|
||||
{ id: 'toggle-terminal', label: 'Toggle Terminal', shortcut: 'Ctrl+J', action: () => dispatch('toggle-terminal') },
|
||||
{ id: 'reload-plugins', label: 'Reload Plugins', action: () => dispatch('reload-plugins') },
|
||||
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', shortcut: 'Ctrl+B', action: () => dispatch('toggle-sidebar') },
|
||||
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'Ctrl+=', action: () => dispatch('zoom-in') },
|
||||
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
|
||||
];
|
||||
|
||||
let query = $state('');
|
||||
|
|
|
|||
226
ui-electrobun/src/mainview/DocsTab.svelte
Normal file
226
ui-electrobun/src/mainview/DocsTab.svelte
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
let { cwd }: Props = $props();
|
||||
|
||||
interface DocFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
let files = $state<DocFile[]>([]);
|
||||
let selectedFile = $state<DocFile | null>(null);
|
||||
let content = $state('');
|
||||
let renderedHtml = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
function expandHome(p: string): string {
|
||||
if (p.startsWith('~/')) return p.replace('~', '/home/' + (typeof process !== 'undefined' ? process.env.USER : 'user'));
|
||||
return p;
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const resolved = expandHome(cwd);
|
||||
const res = await appRpc.request['files.list']({ path: resolved });
|
||||
if (res.error) return;
|
||||
files = res.entries
|
||||
.filter((e: { name: string; type: string }) => e.type === 'file' && e.name.endsWith('.md'))
|
||||
.map((e: { name: string }) => ({ name: e.name, path: `${resolved}/${e.name}` }));
|
||||
} catch (err) {
|
||||
console.error('[DocsTab] list error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(file: DocFile) {
|
||||
selectedFile = file;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await appRpc.request['files.read']({ path: file.path });
|
||||
if (res.error || !res.content) {
|
||||
content = '';
|
||||
renderedHtml = `<p class="doc-error">${res.error ?? 'Empty file'}</p>`;
|
||||
} else {
|
||||
content = res.content;
|
||||
renderedHtml = renderMarkdown(content);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DocsTab] read error:', err);
|
||||
renderedHtml = '<p class="doc-error">Failed to read file</p>';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
/** Simple markdown-to-HTML (no external dep). Handles headers, code blocks, bold, italic, links, lists. */
|
||||
function renderMarkdown(md: string): string {
|
||||
let html = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) =>
|
||||
`<pre class="doc-code"><code class="lang-${lang}">${code.trim()}</code></pre>`
|
||||
);
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');
|
||||
// Headers
|
||||
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
// Bold + italic
|
||||
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||
// Unordered lists
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
|
||||
html = html.replace(/<\/ul>\s*<ul>/g, '');
|
||||
// Paragraphs (lines not already wrapped)
|
||||
html = html.replace(/^(?!<[huplo])((?!\s*$).+)$/gm, '<p>$1</p>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
onMount(() => { loadFiles(); });
|
||||
</script>
|
||||
|
||||
<div class="docs-tab">
|
||||
<div class="docs-sidebar">
|
||||
<div class="docs-header">Markdown files</div>
|
||||
{#if files.length === 0}
|
||||
<div class="docs-empty">No .md files in project</div>
|
||||
{:else}
|
||||
{#each files as file}
|
||||
<button
|
||||
class="docs-file"
|
||||
class:active={selectedFile?.path === file.path}
|
||||
onclick={() => selectFile(file)}
|
||||
>{file.name}</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="docs-content">
|
||||
{#if loading}
|
||||
<div class="docs-placeholder">Loading...</div>
|
||||
{:else if !selectedFile}
|
||||
<div class="docs-placeholder">Select a file to view</div>
|
||||
{:else}
|
||||
<div class="docs-rendered">
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.docs-tab { display: flex; height: 100%; overflow: hidden; }
|
||||
|
||||
.docs-sidebar {
|
||||
width: 10rem;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ctp-overlay0);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.docs-empty {
|
||||
padding: 1rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.docs-file {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--term-font-family);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.docs-file:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.docs-file.active { background: color-mix(in srgb, var(--ctp-blue) 12%, transparent); color: var(--ctp-blue); }
|
||||
|
||||
.docs-content { flex: 1; overflow-y: auto; padding: 0.75rem; min-width: 0; }
|
||||
|
||||
.docs-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.docs-rendered {
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docs-rendered :global(h1) { font-size: 1.25rem; color: var(--ctp-text); margin: 0.75rem 0 0.375rem; }
|
||||
.docs-rendered :global(h2) { font-size: 1.0625rem; color: var(--ctp-text); margin: 0.625rem 0 0.3rem; }
|
||||
.docs-rendered :global(h3) { font-size: 0.9375rem; color: var(--ctp-text); margin: 0.5rem 0 0.25rem; }
|
||||
.docs-rendered :global(p) { margin: 0.25rem 0; }
|
||||
.docs-rendered :global(a) { color: var(--ctp-blue); text-decoration: none; }
|
||||
.docs-rendered :global(a:hover) { text-decoration: underline; }
|
||||
.docs-rendered :global(strong) { color: var(--ctp-text); }
|
||||
.docs-rendered :global(ul) { padding-left: 1.25rem; margin: 0.25rem 0; }
|
||||
.docs-rendered :global(li) { margin: 0.125rem 0; }
|
||||
|
||||
.docs-rendered :global(.doc-code) {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
overflow-x: auto;
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.docs-rendered :global(.doc-inline-code) {
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 0.2rem;
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.docs-rendered :global(.doc-error) { color: var(--ctp-red); font-style: italic; }
|
||||
</style>
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
type TrustLevel = 'human' | 'agent' | 'auto';
|
||||
|
||||
interface MemoryFragment {
|
||||
|
|
@ -10,89 +13,133 @@
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
const MEMORIES: MemoryFragment[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Agent Orchestrator — Tech Stack',
|
||||
body: 'Tauri 2.x + Svelte 5 frontend. Rust backend with rusqlite (WAL mode). Agent sessions via @anthropic-ai/claude-agent-sdk query(). Sidecar uses stdio NDJSON.',
|
||||
tags: ['agor', 'tech-stack', 'architecture'],
|
||||
trust: 'human',
|
||||
updatedAt: '2026-03-20',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'btmsg SQLite conventions',
|
||||
body: 'All queries use named column access (row.get("column_name")) — never positional indices. Rust structs use #[serde(rename_all = "camelCase")].',
|
||||
tags: ['agor', 'database', 'btmsg'],
|
||||
trust: 'agent',
|
||||
updatedAt: '2026-03-19',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Wake Scheduler — 3 strategies',
|
||||
body: 'persistent=resume prompt, on-demand=fresh session, smart=threshold-gated on-demand. 6 wake signals from S-3 hybrid tribunal. Pure scorer in wake-scorer.ts (24 tests).',
|
||||
tags: ['agor', 'wake-scheduler', 'agents'],
|
||||
trust: 'agent',
|
||||
updatedAt: '2026-03-18',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Svelte 5 runes file extension rule',
|
||||
body: 'Store files using Svelte 5 runes ($state, $derived) MUST have .svelte.ts extension. Plain .ts compiles but fails at runtime with "rune_outside_svelte".',
|
||||
tags: ['agor', 'svelte', 'conventions'],
|
||||
trust: 'auto',
|
||||
updatedAt: '2026-03-17',
|
||||
},
|
||||
];
|
||||
let memories = $state<MemoryFragment[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let loading = $state(false);
|
||||
let hasMemora = $state(true);
|
||||
|
||||
function parseMemory(raw: {
|
||||
id: number; content: string; tags: string;
|
||||
metadata: string; updatedAt: string;
|
||||
}): MemoryFragment {
|
||||
const content = raw.content ?? '';
|
||||
const firstLine = content.split('\n')[0] ?? '';
|
||||
const title = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine;
|
||||
const body = content.length > firstLine.length ? content.slice(firstLine.length + 1).trim() : '';
|
||||
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw.tags ?? '[]');
|
||||
tags = Array.isArray(parsed) ? parsed : [];
|
||||
} catch { /* keep empty */ }
|
||||
|
||||
// Infer trust from metadata
|
||||
let trust: TrustLevel = 'auto';
|
||||
try {
|
||||
const meta = JSON.parse(raw.metadata ?? '{}');
|
||||
if (meta.source === 'human') trust = 'human';
|
||||
else if (meta.source === 'agent') trust = 'agent';
|
||||
} catch { /* keep auto */ }
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
title: title || `Memory #${raw.id}`,
|
||||
body,
|
||||
tags,
|
||||
trust,
|
||||
updatedAt: raw.updatedAt?.split('T')[0] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMemories() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await appRpc.request['memora.list']({ limit: 30 });
|
||||
if (res.memories.length === 0) {
|
||||
// Try search to see if DB exists
|
||||
hasMemora = true;
|
||||
}
|
||||
memories = res.memories.map(parseMemory);
|
||||
} catch {
|
||||
hasMemora = false;
|
||||
memories = [];
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.trim()) {
|
||||
await loadMemories();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await appRpc.request['memora.search']({ query: searchQuery.trim(), limit: 30 });
|
||||
memories = res.memories.map(parseMemory);
|
||||
} catch {
|
||||
memories = [];
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
const TRUST_LABELS: Record<TrustLevel, string> = {
|
||||
human: 'Human',
|
||||
agent: 'Agent',
|
||||
auto: 'Auto',
|
||||
};
|
||||
|
||||
onMount(() => { loadMemories(); });
|
||||
</script>
|
||||
|
||||
<div class="memory-tab">
|
||||
<div class="memory-header">
|
||||
<span class="memory-count">{MEMORIES.length} fragments</span>
|
||||
<span class="memory-hint">via Memora</span>
|
||||
<input
|
||||
class="memory-search"
|
||||
type="text"
|
||||
placeholder="Search memories..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
/>
|
||||
<span class="memory-count">{memories.length} found</span>
|
||||
<span class="memory-hint">{hasMemora ? 'via Memora' : 'Memora not found'}</span>
|
||||
</div>
|
||||
|
||||
<div class="memory-list">
|
||||
{#each MEMORIES as mem (mem.id)}
|
||||
<article class="memory-card">
|
||||
<div class="memory-card-top">
|
||||
<span class="memory-title">{mem.title}</span>
|
||||
<span class="trust-badge trust-{mem.trust}" title="Source: {TRUST_LABELS[mem.trust]}">
|
||||
{TRUST_LABELS[mem.trust]}
|
||||
</span>
|
||||
</div>
|
||||
<p class="memory-body">{mem.body}</p>
|
||||
<div class="memory-footer">
|
||||
<div class="memory-tags">
|
||||
{#each mem.tags as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
{#if loading}
|
||||
<div class="memory-loading">Loading...</div>
|
||||
{:else if memories.length === 0}
|
||||
<div class="memory-loading">{hasMemora ? 'No memories found' : 'Memora DB not available (~/.local/share/memora/memories.db)'}</div>
|
||||
{:else}
|
||||
{#each memories as mem (mem.id)}
|
||||
<article class="memory-card">
|
||||
<div class="memory-card-top">
|
||||
<span class="memory-title">{mem.title}</span>
|
||||
<span class="trust-badge trust-{mem.trust}" title="Source: {TRUST_LABELS[mem.trust]}">
|
||||
{TRUST_LABELS[mem.trust]}
|
||||
</span>
|
||||
</div>
|
||||
<span class="memory-date">{mem.updatedAt}</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{#if mem.body}
|
||||
<p class="memory-body">{mem.body.slice(0, 200)}{mem.body.length > 200 ? '...' : ''}</p>
|
||||
{/if}
|
||||
<div class="memory-footer">
|
||||
<div class="memory-tags">
|
||||
{#each mem.tags as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="memory-date">{mem.updatedAt}</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.memory-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.memory-tab { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.memory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
|
|
@ -100,109 +147,52 @@
|
|||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.memory-count { color: var(--ctp-text); font-weight: 500; }
|
||||
.memory-hint { color: var(--ctp-overlay0); font-style: italic; }
|
||||
.memory-search {
|
||||
flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
|
||||
color: var(--ctp-text); font-size: 0.75rem; font-family: var(--ui-font-family);
|
||||
}
|
||||
.memory-search:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.memory-search::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.memory-count { color: var(--ctp-text); font-weight: 500; white-space: nowrap; }
|
||||
.memory-hint { color: var(--ctp-overlay0); font-style: italic; white-space: nowrap; }
|
||||
|
||||
.memory-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex: 1; overflow-y: auto; padding: 0.375rem;
|
||||
display: flex; flex-direction: column; gap: 0.375rem;
|
||||
}
|
||||
|
||||
.memory-list::-webkit-scrollbar { width: 0.25rem; }
|
||||
.memory-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.memory-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
.memory-loading { padding: 2rem; text-align: center; color: var(--ctp-overlay0); font-size: 0.8125rem; font-style: italic; }
|
||||
|
||||
.memory-card {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem; padding: 0.5rem 0.625rem;
|
||||
display: flex; flex-direction: column; gap: 0.3rem;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.memory-card:hover { border-color: var(--ctp-surface2); }
|
||||
|
||||
.memory-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.memory-card-top { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
|
||||
.memory-title {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.memory-title { flex: 1; font-size: 0.8125rem; font-weight: 600; color: var(--ctp-text); line-height: 1.3; }
|
||||
|
||||
.trust-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0; padding: 0.1rem 0.35rem; border-radius: 0.25rem;
|
||||
font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.trust-human { background: color-mix(in srgb, var(--ctp-green) 15%, transparent); color: var(--ctp-green); }
|
||||
.trust-agent { background: color-mix(in srgb, var(--ctp-blue) 15%, transparent); color: var(--ctp-blue); }
|
||||
.trust-auto { background: color-mix(in srgb, var(--ctp-overlay1) 15%, transparent); color: var(--ctp-overlay1); }
|
||||
|
||||
.trust-human {
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
.memory-body { margin: 0; font-size: 0.75rem; color: var(--ctp-subtext1); line-height: 1.45; font-family: var(--ui-font-family); }
|
||||
|
||||
.trust-agent {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.trust-auto {
|
||||
background: color-mix(in srgb, var(--ctp-overlay1) 15%, transparent);
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.memory-body {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext1);
|
||||
line-height: 1.45;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.memory-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.memory-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.05rem 0.3rem;
|
||||
background: var(--ctp-surface1);
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-family: var(--term-font-family);
|
||||
}
|
||||
|
||||
.memory-date {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-footer { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.1rem; }
|
||||
.memory-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; flex: 1; }
|
||||
.tag { padding: 0.05rem 0.3rem; background: var(--ctp-surface1); border-radius: 0.2rem; font-size: 0.625rem; color: var(--ctp-overlay1); font-family: var(--term-font-family); }
|
||||
.memory-date { font-size: 0.625rem; color: var(--ctp-overlay0); white-space: nowrap; flex-shrink: 0; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
import MemoryTab from './MemoryTab.svelte';
|
||||
import CommsTab from './CommsTab.svelte';
|
||||
import TaskBoardTab from './TaskBoardTab.svelte';
|
||||
import DocsTab from './DocsTab.svelte';
|
||||
import SshTab from './SshTab.svelte';
|
||||
import {
|
||||
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
||||
loadLastSession,
|
||||
|
|
@ -61,6 +63,32 @@
|
|||
let agentCost = $derived(session?.costUsd ?? 0);
|
||||
let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0));
|
||||
let agentModel = $derived(session?.model ?? model);
|
||||
let agentInputTokens = $derived(session?.inputTokens ?? 0);
|
||||
let agentOutputTokens = $derived(session?.outputTokens ?? 0);
|
||||
|
||||
// Context limit per model (approximate)
|
||||
const MODEL_LIMITS: Record<string, number> = {
|
||||
'claude-opus-4-5': 200000,
|
||||
'claude-sonnet-4-5': 200000,
|
||||
'claude-haiku-4-5': 200000,
|
||||
'gpt-5.4': 128000,
|
||||
'qwen3:8b': 32000,
|
||||
};
|
||||
let contextLimit = $derived(MODEL_LIMITS[agentModel] ?? 200000);
|
||||
let computedContextPct = $derived(
|
||||
agentInputTokens > 0 ? Math.min(100, Math.round((agentInputTokens / contextLimit) * 100)) : (contextPct ?? 0)
|
||||
);
|
||||
|
||||
// File references from tool_call messages
|
||||
let fileRefs = $derived(
|
||||
agentMessages
|
||||
.filter((m) => m.role === 'tool-call' && m.toolPath)
|
||||
.map((m) => m.toolPath!)
|
||||
.filter((p, i, arr) => arr.indexOf(p) === i)
|
||||
.slice(0, 20)
|
||||
);
|
||||
|
||||
let turnCount = $derived(agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length);
|
||||
|
||||
// ── Demo messages (fallback when no real session) ────────────────
|
||||
const demoMessages: AgentMessage[] = [];
|
||||
|
|
@ -258,7 +286,7 @@
|
|||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<TerminalTabs projectId={id} {accent} />
|
||||
<TerminalTabs projectId={id} {accent} {cwd} />
|
||||
</div>
|
||||
|
||||
<!-- Docs tab -->
|
||||
|
|
@ -270,7 +298,7 @@
|
|||
role="tabpanel"
|
||||
aria-label="Docs"
|
||||
>
|
||||
<div class="placeholder-pane">No markdown files open</div>
|
||||
<DocsTab {cwd} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -286,30 +314,48 @@
|
|||
<div class="context-pane">
|
||||
<div class="ctx-stats-row">
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Tokens used</span>
|
||||
<span class="ctx-stat-value">{agentTokens.toLocaleString()}</span>
|
||||
<span class="ctx-stat-label">Input tokens</span>
|
||||
<span class="ctx-stat-value">{agentInputTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Context %</span>
|
||||
<span class="ctx-stat-value">{contextPct}%</span>
|
||||
<span class="ctx-stat-label">Output tokens</span>
|
||||
<span class="ctx-stat-value">{agentOutputTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Context</span>
|
||||
<span class="ctx-stat-value">{computedContextPct}%</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Model</span>
|
||||
<span class="ctx-stat-value">{agentModel}</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Turns</span>
|
||||
<span class="ctx-stat-value">{turnCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-meter-wrap" title="{contextPct}% context used">
|
||||
<div class="ctx-meter-bar" style:width="{contextPct}%"
|
||||
class:meter-warn={contextPct >= 75}
|
||||
class:meter-danger={contextPct >= 90}
|
||||
<div class="ctx-meter-wrap" title="{computedContextPct}% of {contextLimit.toLocaleString()} tokens">
|
||||
<div class="ctx-meter-bar" style:width="{computedContextPct}%"
|
||||
class:meter-warn={computedContextPct >= 75}
|
||||
class:meter-danger={computedContextPct >= 90}
|
||||
></div>
|
||||
</div>
|
||||
{#if fileRefs.length > 0}
|
||||
<div class="ctx-turn-list">
|
||||
<div class="ctx-section-label">Turn breakdown</div>
|
||||
{#each displayMessages.slice(0, 5) as msg}
|
||||
<div class="ctx-section-label">File references ({fileRefs.length})</div>
|
||||
{#each fileRefs as ref}
|
||||
<div class="ctx-turn-row">
|
||||
<span class="ctx-turn-preview" title={ref}>{ref}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ctx-turn-list">
|
||||
<div class="ctx-section-label">Recent turns</div>
|
||||
{#each displayMessages.slice(-10) as msg}
|
||||
<div class="ctx-turn-row">
|
||||
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
|
||||
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '…' : ''}</span>
|
||||
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '...' : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -339,7 +385,7 @@
|
|||
role="tabpanel"
|
||||
aria-label="SSH"
|
||||
>
|
||||
<div class="placeholder-pane">No SSH connections configured</div>
|
||||
<SshTab projectId={id} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
246
ui-electrobun/src/mainview/SshTab.svelte
Normal file
246
ui-electrobun/src/mainview/SshTab.svelte
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
|
||||
interface SshConfig {
|
||||
id: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
keyPath: string;
|
||||
}
|
||||
|
||||
let connections = $state<SshConfig[]>([]);
|
||||
let editing = $state<SshConfig | null>(null);
|
||||
let showForm = $state(false);
|
||||
|
||||
// Form fields
|
||||
let formHost = $state('');
|
||||
let formPort = $state(22);
|
||||
let formUser = $state('');
|
||||
let formKeyPath = $state('~/.ssh/id_rsa');
|
||||
|
||||
function newConnection() {
|
||||
formHost = '';
|
||||
formPort = 22;
|
||||
formUser = '';
|
||||
formKeyPath = '~/.ssh/id_rsa';
|
||||
editing = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function editConnection(conn: SshConfig) {
|
||||
formHost = conn.host;
|
||||
formPort = conn.port;
|
||||
formUser = conn.user;
|
||||
formKeyPath = conn.keyPath;
|
||||
editing = conn;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showForm = false;
|
||||
editing = null;
|
||||
}
|
||||
|
||||
async function saveConnection() {
|
||||
if (!formHost.trim() || !formUser.trim()) return;
|
||||
|
||||
const id = editing?.id ?? `ssh-${Date.now()}`;
|
||||
const config: SshConfig = {
|
||||
id,
|
||||
host: formHost.trim(),
|
||||
port: formPort,
|
||||
user: formUser.trim(),
|
||||
keyPath: formKeyPath.trim(),
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
connections = connections.map(c => c.id === id ? config : c);
|
||||
} else {
|
||||
connections = [...connections, config];
|
||||
}
|
||||
|
||||
// Persist to settings
|
||||
await appRpc.request['settings.set']({
|
||||
key: `ssh_configs_${projectId}`,
|
||||
value: JSON.stringify(connections),
|
||||
}).catch(console.error);
|
||||
|
||||
showForm = false;
|
||||
editing = null;
|
||||
}
|
||||
|
||||
async function deleteConnection(id: string) {
|
||||
connections = connections.filter(c => c.id !== id);
|
||||
await appRpc.request['settings.set']({
|
||||
key: `ssh_configs_${projectId}`,
|
||||
value: JSON.stringify(connections),
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
function connectSsh(conn: SshConfig) {
|
||||
// Spawn a PTY with ssh command
|
||||
const sessionId = `ssh-${conn.id}-${Date.now()}`;
|
||||
const args = ['-p', String(conn.port), `${conn.user}@${conn.host}`];
|
||||
if (conn.keyPath) args.unshift('-i', conn.keyPath);
|
||||
|
||||
appRpc.request['pty.create']({
|
||||
sessionId,
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
}).catch(console.error);
|
||||
|
||||
// Write the ssh command after a short delay to let the shell start
|
||||
setTimeout(() => {
|
||||
const cmd = `/usr/bin/ssh ${args.join(' ')}\n`;
|
||||
appRpc.request['pty.write']({ sessionId, data: cmd }).catch(console.error);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const { value } = await appRpc.request['settings.get']({ key: `ssh_configs_${projectId}` });
|
||||
if (value) {
|
||||
connections = JSON.parse(value);
|
||||
}
|
||||
} catch { /* no saved connections */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ssh-tab">
|
||||
<div class="ssh-header">
|
||||
<span class="ssh-title">SSH Connections</span>
|
||||
<button class="ssh-add-btn" onclick={newConnection}>+ Add</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="ssh-form">
|
||||
<div class="ssh-form-row">
|
||||
<label class="ssh-label">Host</label>
|
||||
<input class="ssh-input" bind:value={formHost} placeholder="example.com" />
|
||||
</div>
|
||||
<div class="ssh-form-row">
|
||||
<label class="ssh-label">Port</label>
|
||||
<input class="ssh-input ssh-port" type="number" bind:value={formPort} min="1" max="65535" />
|
||||
</div>
|
||||
<div class="ssh-form-row">
|
||||
<label class="ssh-label">User</label>
|
||||
<input class="ssh-input" bind:value={formUser} placeholder="root" />
|
||||
</div>
|
||||
<div class="ssh-form-row">
|
||||
<label class="ssh-label">Key</label>
|
||||
<input class="ssh-input" bind:value={formKeyPath} placeholder="~/.ssh/id_rsa" />
|
||||
</div>
|
||||
<div class="ssh-form-actions">
|
||||
<button class="ssh-cancel" onclick={cancelForm}>Cancel</button>
|
||||
<button class="ssh-save" onclick={saveConnection}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ssh-list">
|
||||
{#if connections.length === 0 && !showForm}
|
||||
<div class="ssh-empty">No SSH connections configured</div>
|
||||
{/if}
|
||||
{#each connections as conn (conn.id)}
|
||||
<div class="ssh-card">
|
||||
<div class="ssh-card-info">
|
||||
<span class="ssh-card-name">{conn.user}@{conn.host}</span>
|
||||
<span class="ssh-card-port">:{conn.port}</span>
|
||||
</div>
|
||||
<div class="ssh-card-actions">
|
||||
<button class="ssh-action-btn" onclick={() => connectSsh(conn)} title="Connect">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</button>
|
||||
<button class="ssh-action-btn" onclick={() => editConnection(conn)} title="Edit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="ssh-action-btn ssh-delete" onclick={() => deleteConnection(conn.id)} title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ssh-tab { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.ssh-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ssh-title { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
|
||||
|
||||
.ssh-add-btn {
|
||||
padding: 0.15rem 0.5rem; background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
border: 1px solid var(--ctp-green); border-radius: 0.25rem; color: var(--ctp-green);
|
||||
font-size: 0.6875rem; cursor: pointer; font-family: var(--ui-font-family);
|
||||
}
|
||||
.ssh-add-btn:hover { background: color-mix(in srgb, var(--ctp-green) 25%, transparent); }
|
||||
|
||||
.ssh-form {
|
||||
padding: 0.5rem 0.625rem; background: var(--ctp-surface0);
|
||||
border-bottom: 1px solid var(--ctp-surface1);
|
||||
display: flex; flex-direction: column; gap: 0.375rem; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ssh-form-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ssh-label { width: 2.5rem; font-size: 0.6875rem; color: var(--ctp-overlay0); font-weight: 600; flex-shrink: 0; }
|
||||
|
||||
.ssh-input {
|
||||
flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-base); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.75rem; font-family: var(--term-font-family);
|
||||
}
|
||||
.ssh-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.ssh-port { max-width: 4rem; }
|
||||
|
||||
.ssh-form-actions { display: flex; gap: 0.375rem; justify-content: flex-end; margin-top: 0.25rem; }
|
||||
|
||||
.ssh-cancel, .ssh-save {
|
||||
padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.6875rem;
|
||||
cursor: pointer; font-family: var(--ui-font-family);
|
||||
}
|
||||
.ssh-cancel { background: transparent; border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); }
|
||||
.ssh-cancel:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.ssh-save { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); border: 1px solid var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.ssh-save:hover { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
|
||||
|
||||
.ssh-list { flex: 1; overflow-y: auto; padding: 0.375rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.ssh-empty { padding: 2rem; text-align: center; color: var(--ctp-overlay0); font-size: 0.8125rem; font-style: italic; }
|
||||
|
||||
.ssh-card {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; transition: border-color 0.1s;
|
||||
}
|
||||
.ssh-card:hover { border-color: var(--ctp-surface2); }
|
||||
|
||||
.ssh-card-info { display: flex; align-items: baseline; gap: 0.125rem; }
|
||||
.ssh-card-name { font-size: 0.8125rem; color: var(--ctp-text); font-family: var(--term-font-family); }
|
||||
.ssh-card-port { font-size: 0.6875rem; color: var(--ctp-overlay0); }
|
||||
|
||||
.ssh-card-actions { display: flex; gap: 0.25rem; }
|
||||
|
||||
.ssh-action-btn {
|
||||
width: 1.375rem; height: 1.375rem; background: transparent; border: none;
|
||||
color: var(--ctp-overlay1); cursor: pointer; border-radius: 0.2rem;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
}
|
||||
.ssh-action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.ssh-action-btn.ssh-delete:hover { color: var(--ctp-red); }
|
||||
.ssh-action-btn svg { width: 0.75rem; height: 0.75rem; }
|
||||
</style>
|
||||
|
|
@ -94,8 +94,18 @@
|
|||
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
|
||||
|
||||
void (async () => {
|
||||
// Read default_shell and default_cwd from settings if not provided
|
||||
let effectiveCwd = cwd;
|
||||
try {
|
||||
const { settings } = await appRpc.request['settings.getAll']({});
|
||||
if (!effectiveCwd && settings['default_cwd']) {
|
||||
effectiveCwd = settings['default_cwd'];
|
||||
}
|
||||
// default_shell is handled by agor-ptyd, not needed in create params
|
||||
} catch { /* use provided or defaults */ }
|
||||
|
||||
const { cols, rows } = term;
|
||||
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd });
|
||||
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd });
|
||||
if (!result?.ok) {
|
||||
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
|
||||
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
interface Props {
|
||||
projectId: string;
|
||||
accent?: string;
|
||||
/** Project working directory — passed to terminal shells. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
let { projectId, accent = 'var(--ctp-mauve)' }: Props = $props();
|
||||
let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props();
|
||||
|
||||
interface TermTab {
|
||||
id: string;
|
||||
|
|
@ -121,7 +123,7 @@
|
|||
{#each tabs as tab (tab.id)}
|
||||
{#if mounted.has(tab.id)}
|
||||
<div class="term-pane" style:display={activeTabId === tab.id ? 'flex' : 'none'}>
|
||||
<Terminal sessionId={tab.id} />
|
||||
<Terminal sessionId={tab.id} {cwd} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,48 @@ interface StartOptions {
|
|||
permissionMode?: string;
|
||||
claudeConfigDir?: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
additionalDirectories?: string[];
|
||||
worktreeName?: string;
|
||||
}
|
||||
|
||||
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
|
||||
|
||||
type ToastFn = (message: string, variant: 'success' | 'warning' | 'error' | 'info') => void;
|
||||
let _toastFn: ToastFn | null = null;
|
||||
|
||||
/** Register a toast callback for agent notifications. */
|
||||
export function setAgentToastFn(fn: ToastFn): void { _toastFn = fn; }
|
||||
|
||||
function emitToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
|
||||
_toastFn?.(message, variant);
|
||||
}
|
||||
|
||||
// ── Stall detection ───────────────────────────────────────────────────────────
|
||||
|
||||
const stallTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const DEFAULT_STALL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function resetStallTimer(sessionId: string, projectId: string): void {
|
||||
const existing = stallTimers.get(sessionId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
stallTimers.delete(sessionId);
|
||||
const session = sessions[sessionId];
|
||||
if (session && session.status === 'running') {
|
||||
emitToast(`Agent stalled on ${projectId} (no activity for 15 min)`, 'warning');
|
||||
}
|
||||
}, DEFAULT_STALL_MS);
|
||||
|
||||
stallTimers.set(sessionId, timer);
|
||||
}
|
||||
|
||||
function clearStallTimer(sessionId: string): void {
|
||||
const timer = stallTimers.get(sessionId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
stallTimers.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Env var validation (Fix #14) ─────────────────────────────────────────────
|
||||
|
|
@ -153,6 +195,8 @@ function ensureListeners() {
|
|||
if (converted.length > 0) {
|
||||
session.messages = [...session.messages, ...converted];
|
||||
persistMessages(session);
|
||||
// Reset stall timer on activity
|
||||
resetStallTimer(payload.sessionId, session.projectId);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -171,6 +215,15 @@ function ensureListeners() {
|
|||
// Persist on every status change
|
||||
persistSession(session);
|
||||
|
||||
// Emit toast notification on completion
|
||||
if (session.status === 'done') {
|
||||
clearStallTimer(payload.sessionId);
|
||||
emitToast(`Agent completed on ${session.projectId}`, 'success');
|
||||
} else if (session.status === 'error') {
|
||||
clearStallTimer(payload.sessionId);
|
||||
emitToast(`Agent error on ${session.projectId}: ${payload.error ?? 'unknown'}`, 'error');
|
||||
}
|
||||
|
||||
// Schedule cleanup after done/error (Fix #2)
|
||||
if (session.status === 'done' || session.status === 'error') {
|
||||
// Flush any pending message persistence immediately
|
||||
|
|
@ -366,6 +419,8 @@ export async function startAgent(
|
|||
// Read settings defaults if not explicitly provided (Fix #5)
|
||||
let permissionMode = options.permissionMode;
|
||||
let systemPrompt = options.systemPrompt;
|
||||
let defaultModel = options.model;
|
||||
let cwd = options.cwd;
|
||||
try {
|
||||
const { settings } = await appRpc.request['settings.getAll']({});
|
||||
if (!permissionMode && settings['permission_mode']) {
|
||||
|
|
@ -374,6 +429,17 @@ export async function startAgent(
|
|||
if (!systemPrompt && settings['system_prompt_template']) {
|
||||
systemPrompt = settings['system_prompt_template'];
|
||||
}
|
||||
if (!cwd && settings['default_cwd']) {
|
||||
cwd = settings['default_cwd'];
|
||||
}
|
||||
// Read default model from provider_settings if not specified
|
||||
if (!defaultModel && settings['provider_settings']) {
|
||||
try {
|
||||
const providerSettings = JSON.parse(settings['provider_settings']);
|
||||
const provConfig = providerSettings[provider];
|
||||
if (provConfig?.defaultModel) defaultModel = provConfig.defaultModel;
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
} catch { /* use provided or defaults */ }
|
||||
|
||||
// Create reactive session state
|
||||
|
|
@ -391,17 +457,18 @@ export async function startAgent(
|
|||
costUsd: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
model: options.model ?? 'claude-opus-4-5',
|
||||
model: defaultModel ?? 'claude-opus-4-5',
|
||||
};
|
||||
|
||||
projectSessionMap.set(projectId, sessionId);
|
||||
resetStallTimer(sessionId, projectId);
|
||||
|
||||
const result = await appRpc.request['agent.start']({
|
||||
sessionId,
|
||||
provider: provider as 'claude' | 'codex' | 'ollama',
|
||||
prompt,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
cwd,
|
||||
model: defaultModel,
|
||||
systemPrompt: systemPrompt,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: permissionMode,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ export type PtyRPCRequests = {
|
|||
params: { id: string; config: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Delete a project by id. */
|
||||
"settings.deleteProject": {
|
||||
params: { id: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
|
||||
// ── Custom Themes RPC ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -127,6 +132,47 @@ export type PtyRPCRequests = {
|
|||
params: Record<string, never>;
|
||||
response: { groups: Array<{ id: string; name: string; icon: string; position: number }> };
|
||||
};
|
||||
/** Create a new group. */
|
||||
"groups.create": {
|
||||
params: { id: string; name: string; icon: string; position: number };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Delete a group by id. */
|
||||
"groups.delete": {
|
||||
params: { id: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
|
||||
// ── Memora RPC ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Search memories by query text (FTS5). */
|
||||
"memora.search": {
|
||||
params: { query: string; limit?: number };
|
||||
response: {
|
||||
memories: Array<{
|
||||
id: number;
|
||||
content: string;
|
||||
tags: string;
|
||||
metadata: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
/** List recent memories. */
|
||||
"memora.list": {
|
||||
params: { limit?: number; tag?: string };
|
||||
response: {
|
||||
memories: Array<{
|
||||
id: number;
|
||||
content: string;
|
||||
tags: string;
|
||||
metadata: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Project clone RPC ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -192,6 +238,8 @@ export type PtyRPCRequests = {
|
|||
permissionMode?: string;
|
||||
claudeConfigDir?: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
additionalDirectories?: string[];
|
||||
worktreeName?: string;
|
||||
};
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue