diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index de3131a..c7702d0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,12 +16,6 @@ jobs: with: install: true - run: bun install --frozen-lockfile - - run: npm install -g @earendil-works/pi-coding-agent - - run: bunx tsc -p tsconfig.main.json - - run: bunx tsc -p tsconfig.preload.json - - run: bunx vite build - - run: mise run dev:vite & - - run: bunx wait-on http://localhost:5173 - run: mise run test:e2e - name: upload test artefacts if: always() @@ -31,4 +25,4 @@ jobs: path: | /tmp/pidex-*.png test-results/ - if-no-files-found: ignore \ No newline at end of file + if-no-files-found: ignore diff --git a/README.md b/README.md index b320451..31b1175 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ $ mise run dev # starts Vite + Electron For a production build: `mise run build` then `mise run start`. Native installers: `mise run package`. +### Tests + +```bash +$ bun run test:unit # parsers, session archives, workspace and skill discovery +$ bun run test:integration # React component behavior in jsdom +$ bun run test:e2e # real Electron UI on macOS +``` + +The E2E suite compiles main/preload, starts an isolated renderer server on a free port, and creates temporary Git workspaces, sessions, and app settings. It uses a deterministic Pi RPC fixture, so it needs no provider credentials. Screenshots are written to `test-results/workbench/`. Every PR runs all three test lanes; `mise run build` also runs lint, typecheck, unit and component tests. + ## Architecture ``` diff --git a/src/main/index.ts b/src/main/index.ts index f6001e4..8dc8f40 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,10 +1,14 @@ import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import fs from 'fs'; import path from 'path'; -import { listSessions, listWorkspaces, SESSIONS_ROOT } from './sessions'; +import { listSessions, listWorkspaces } from './sessions'; +import { sessionPath } from './workbench'; import { RpcSession } from './rpc'; +import { registerWorkbench, sessionDefaults } from './workbench-ipc'; const isDev = !app.isPackaged; +if (isDev && process.env.PIDEX_E2E_USER_DATA) + app.setPath('userData', process.env.PIDEX_E2E_USER_DATA); const sessions = new Map(); let sessionSeq = 0; let mainWindow: BrowserWindow | null = null; @@ -14,10 +18,13 @@ function customWorkspacesFile(): string { } function readCustomWorkspaces(): string[] { + if (process.env.PIDEX_E2E_CWD) return [process.env.PIDEX_E2E_CWD]; try { const raw = fs.readFileSync(customWorkspacesFile(), 'utf8'); const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === 'string') : []; + return Array.isArray(parsed) + ? parsed.filter((p): p is string => typeof p === 'string') + : []; } catch { return []; } @@ -26,7 +33,10 @@ function readCustomWorkspaces(): string[] { function addCustomWorkspace(cwd: string): void { const existing = readCustomWorkspaces(); if (!existing.includes(cwd)) { - fs.writeFileSync(customWorkspacesFile(), JSON.stringify([...existing, cwd], null, 2)); + fs.writeFileSync( + customWorkspacesFile(), + JSON.stringify([...existing, cwd], null, 2), + ); } } @@ -53,8 +63,12 @@ function createWindow() { }); if (isDev) { - mainWindow.loadURL('http://localhost:5173'); - mainWindow.webContents.openDevTools({ mode: 'detach' }); + mainWindow.loadURL( + (process.env.PIDEX_E2E_CWD && process.env.PIDEX_E2E_RENDERER_URL) || + 'http://localhost:5173', + ); + if (!process.env.PIDEX_E2E_CWD) + mainWindow.webContents.openDevTools({ mode: 'detach' }); } else { mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); } @@ -64,7 +78,11 @@ function createWindow() { }); } -ipcMain.handle('pidex:workspaces', () => listWorkspaces(readCustomWorkspaces())); +ipcMain.handle('pidex:workspaces', () => + listWorkspaces(readCustomWorkspaces()), +); + +registerWorkbench(() => mainWindow, readCustomWorkspaces, sessions); ipcMain.handle('pidex:workspace:add', async () => { if (!mainWindow) return { error: 'no window' }; @@ -72,7 +90,8 @@ ipcMain.handle('pidex:workspace:add', async () => { title: 'Add workspace', properties: ['openDirectory', 'createDirectory'], }); - if (result.canceled || result.filePaths.length === 0) return { error: 'cancelled' }; + if (result.canceled || result.filePaths.length === 0) + return { error: 'cancelled' }; const cwd = result.filePaths[0]; addCustomWorkspace(cwd); return { cwd }; @@ -85,14 +104,21 @@ ipcMain.handle('pidex:sessions', (_e, dirName: string) => { ipcMain.handle( 'pidex:session/open', - async (_e, req: { cwd: string; sessionFile?: string }) => { + async (_e, req: { cwd: string; sessionFile?: string; fork?: boolean }) => { const cwd = typeof req?.cwd === 'string' ? req.cwd : ''; - const sessionFile = typeof req?.sessionFile === 'string' ? req.sessionFile : undefined; + const sessionFile = + typeof req?.sessionFile === 'string' ? req.sessionFile : undefined; if (!cwd || !fs.existsSync(cwd)) { return { error: 'workspace path does not exist' }; } - if (sessionFile && !path.resolve(sessionFile).startsWith(SESSIONS_ROOT)) { - return { error: 'session file outside pi sessions root' }; + if (sessionFile) { + try { + await sessionPath(sessionFile); + } catch { + return { + error: 'session file outside pi sessions root or unavailable', + }; + } } const id = `s${++sessionSeq}`; @@ -103,11 +129,55 @@ ipcMain.handle( const message = err instanceof Error ? err.message : 'could not start pi'; return { error: message }; } - session.onEvent = (sessionId, event) => sendToRenderer('pidex:session:event', { sessionId, event }); - session.onExit = sessionId => sendToRenderer('pidex:session:exit', { sessionId }); + session.onEvent = (sessionId, event) => + sendToRenderer('pidex:session:event', { sessionId, event }); + session.onExit = (sessionId) => + sendToRenderer('pidex:session:exit', { sessionId }); sessions.set(id, session); - const [state, messages] = await Promise.all([session.getState(), session.getMessages()]); + if (req.fork && sessionFile) { + const cloned = await session.command({ type: 'clone' }); + if ( + !cloned.success || + (cloned.data as { cancelled?: boolean } | undefined)?.cancelled + ) { + sessions.delete(id); + session.dispose(); + return { error: cloned.error ?? 'Session fork was cancelled.' }; + } + } + + if (!sessionFile) { + const defaults = await sessionDefaults(cwd); + if (defaults.model && defaults.provider) { + const changed = await session.command({ + type: 'set_model', + provider: defaults.provider, + modelId: defaults.model, + }); + if (!changed.success) { + sessions.delete(id); + session.dispose(); + return { + error: changed.error ?? 'Could not apply the default model.', + }; + } + } + const thinking = await session.command({ + type: 'set_thinking_level', + level: defaults.thinking, + }); + if (!thinking.success) { + sessions.delete(id); + session.dispose(); + return { error: thinking.error ?? 'Could not apply reasoning effort.' }; + } + } + + const [state, messages] = await Promise.all([ + session.getState(), + session.getMessages(), + ]); const launchError = session.launchError(); if (launchError) { sessions.delete(id); @@ -117,7 +187,9 @@ ipcMain.handle( return { sessionId: id, state: state.success ? state.data : null, - messages: messages.success ? (messages.data as { messages?: unknown[] })?.messages ?? [] : [], + messages: messages.success + ? ((messages.data as { messages?: unknown[] })?.messages ?? []) + : [], }; }, ); @@ -127,7 +199,10 @@ ipcMain.handle( async (_e, req: { sessionId: string; text: string; streaming: boolean }) => { const session = sessions.get(req?.sessionId ?? ''); if (!session) return { error: 'no such session' }; - const response = await session.prompt(String(req.text ?? ''), Boolean(req.streaming)); + const response = await session.prompt( + String(req.text ?? ''), + Boolean(req.streaming), + ); return { success: response.success, error: response.error }; }, ); @@ -141,7 +216,7 @@ ipcMain.handle('pidex:session/commands', async (_e, sessionId: string) => { if (!session) return { commands: [] }; const response = await session.getCommands(); const data = response.data as { commands?: unknown[] } | undefined; - return { commands: response.success ? data?.commands ?? [] : [] }; + return { commands: response.success ? (data?.commands ?? []) : [] }; }); ipcMain.handle('pidex:session/close', (_e, sessionId: string) => { diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 0e4deb8..975dcec 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -3,7 +3,8 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -const PI_MISSING = 'Could not find the pi command. Install it, or set PIDEX_PI to the binary.'; +const PI_MISSING = + 'Could not find the pi command. Install it, or set PIDEX_PI to the binary.'; type RpcEvent = Record & { type: string }; @@ -63,7 +64,7 @@ export function resolvePi( function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const current = (env.PATH ?? '').split(path.delimiter).filter(Boolean); - const extra = piPathDirs().filter(dir => !current.includes(dir)); + const extra = piPathDirs().filter((dir) => !current.includes(dir)); return { ...env, PATH: [...extra, ...current].join(path.delimiter) }; } @@ -75,16 +76,25 @@ class RpcSession { private seq = 0; private closed = false; private failed: string | null = null; - private pending = new Map void; timer: NodeJS.Timeout }>(); + private pending = new Map< + string, + { resolve: (r: RpcResponse) => void; timer: NodeJS.Timeout } + >(); onEvent: (sessionId: string, event: RpcEvent) => void = () => {}; onExit: (sessionId: string, code: number | null) => void = () => {}; - constructor(id: string, cwd: string, sessionFile?: string) { + constructor( + id: string, + cwd: string, + sessionFile?: string, + ephemeral = false, + ) { this.id = id; this.cwd = cwd; const bin = resolvePi(); if (!bin) throw new Error(PI_MISSING); const args = ['--mode', 'rpc']; + if (ephemeral) args.push('--no-session'); if (sessionFile) args.push('--session', sessionFile); this.proc = spawn(bin, args, { cwd, @@ -93,16 +103,16 @@ class RpcSession { }); this.proc.stdout!.setEncoding('utf8'); - this.proc.stdout!.on('data', chunk => this.handleChunk(chunk)); + this.proc.stdout!.on('data', (chunk) => this.handleChunk(chunk)); this.proc.stderr!.setEncoding('utf8'); this.proc.stderr!.on('data', () => {}); this.proc.stdin?.on('error', () => {}); - this.proc.on('error', err => { + this.proc.on('error', (err) => { const code = (err as NodeJS.ErrnoException).code; this.failed = code === 'ENOENT' ? PI_MISSING : err.message; this.close(null); }); - this.proc.on('exit', code => { + this.proc.on('exit', (code) => { this.failed ??= 'process exited'; this.close(code); }); @@ -160,10 +170,16 @@ class RpcSession { }); } const id = `req-${++this.seq}`; - return new Promise(resolve => { + return new Promise((resolve) => { const timer = setTimeout(() => { this.pending.delete(id); - resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: 'timeout' }); + resolve({ + type: 'response', + command: String(cmd.type ?? ''), + success: false, + id, + error: 'timeout', + }); }, 30000); this.pending.set(id, { resolve, timer }); try { @@ -171,8 +187,15 @@ class RpcSession { } catch (err) { clearTimeout(timer); this.pending.delete(id); - const message = err instanceof Error ? err.message : 'could not write to pi'; - resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: message }); + const message = + err instanceof Error ? err.message : 'could not write to pi'; + resolve({ + type: 'response', + command: String(cmd.type ?? ''), + success: false, + id, + error: message, + }); } }); } diff --git a/src/main/workbench-ipc.ts b/src/main/workbench-ipc.ts new file mode 100644 index 0000000..e9c397e --- /dev/null +++ b/src/main/workbench-ipc.ts @@ -0,0 +1,207 @@ +import { app, dialog, ipcMain, shell, type BrowserWindow } from 'electron'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { RpcSession } from './rpc'; +import { listWorkspaces } from './sessions'; +import { + DEFAULTS, + filePatch, + git, + inside, + inspectWorkspace, + listSkills, + readSession, + readText, + validateDefaults, + workspacePath, + type Defaults, +} from './workbench'; + +export function registerWorkbench( + getWindow: () => BrowserWindow | null, + customWorkspaces: () => string[], + sessions: Map, +) { + const settingsFile = () => + path.join(app.getPath('userData'), 'session-defaults.json'); + async function knownWorkspace(cwd: unknown): Promise { + if (typeof cwd !== 'string') throw new Error('Choose a workspace first.'); + const known = await listWorkspaces(customWorkspaces()); + if (!known.some((w) => w.cwd === cwd)) + throw new Error('Unknown workspace.'); + return cwd; + } + async function readDefaults(): Promise> { + try { + const parsed = JSON.parse(await fs.readFile(settingsFile(), 'utf8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return {}; + } + } + const handle = ( + channel: string, + fn: (...args: any[]) => Promise, + ) => { + ipcMain.handle(`pidex:${channel}`, async (_event, ...args) => { + try { + return { data: await fn(...args) }; + } catch (error) { + return { + error: error instanceof Error ? error.message : 'The action failed.', + }; + } + }); + }; + handle('workspace:inspect', async (cwd) => + inspectWorkspace(await knownWorkspace(cwd)), + ); + handle('workspace:read', async (cwd, file) => + readText(await workspacePath(await knownWorkspace(cwd), file)), + ); + handle('workspace:patch', async (cwd, file, staged, untracked) => + filePatch( + await knownWorkspace(cwd), + file, + Boolean(staged), + Boolean(untracked), + ), + ); + handle('workspace:git', async (cwd, action, file) => { + cwd = await knownWorkspace(cwd); + if (action === 'stage' || action === 'unstage') { + if ( + typeof file !== 'string' || + !file || + path.isAbsolute(file) || + !inside(path.resolve(cwd), path.resolve(cwd, file)) + ) + throw new Error('Invalid workspace file.'); + } + if (action === 'stage') { + await workspacePath(cwd, file).catch(async () => { + // A deleted tracked file is still a valid staging target. + await git(cwd, [ + '--literal-pathspecs', + 'ls-files', + '--error-unmatch', + '--', + file, + ]); + }); + await git(cwd, ['--literal-pathspecs', 'add', '--', file]); + } else if (action === 'stage-all') + await git(cwd, ['add', '--all', '--', '.']); + else if (action === 'unstage') { + // git restore --staged also handles paths containing glob metacharacters. + await git(cwd, [ + '--literal-pathspecs', + 'restore', + '--staged', + '--', + file, + ]); + } else if (action === 'commit') { + if (typeof file !== 'string' || !file.trim()) + throw new Error('Enter a commit message.'); + await git(cwd, ['commit', '-m', file.trim()]); + } else throw new Error('Unsupported Git action.'); + return inspectWorkspace(cwd); + }); + handle('session:preview', async (file) => readSession(file)); + handle('workspace:skills', async (cwd) => + listSkills(await knownWorkspace(cwd)), + ); + handle('workspace:open', async (cwd) => { + const error = await shell.openPath(await knownWorkspace(cwd)); + if (error) throw new Error(error); + }); + handle('workspace:attach', async () => { + const win = getWindow(); + if (!win) return []; + const result = await dialog.showOpenDialog(win, { + title: 'Reference files in your prompt', + properties: ['openFile', 'multiSelections'], + }); + return result.canceled ? [] : result.filePaths; + }); + handle( + 'settings:get', + async (cwd) => + (await readDefaults())[await knownWorkspace(cwd)] ?? DEFAULTS, + ); + handle('settings:save', async (cwd, value) => { + cwd = await knownWorkspace(cwd); + const settings = await readDefaults(); + const defaults = validateDefaults(value); + settings[cwd] = defaults; + await fs.mkdir(app.getPath('userData'), { recursive: true }); + const tmp = `${settingsFile()}.tmp`; + await fs.writeFile(tmp, JSON.stringify(settings, null, 2)); + await fs.rename(tmp, settingsFile()); + return defaults; + }); + handle('settings:providers', async () => { + const dir = path.join(os.homedir(), '.pi', 'agent'); + await fs.mkdir(dir, { recursive: true }); + const error = await shell.openPath(dir); + if (error) throw new Error(error); + }); + handle('models:list', async (cwd) => { + cwd = await knownWorkspace(cwd); + const existing = [...sessions.values()].find((s) => s.cwd === cwd); + const runtime = + existing ?? new RpcSession('model-probe', cwd, undefined, true); + try { + const response = await runtime.command({ type: 'get_available_models' }); + if (!response.success) + throw new Error(response.error ?? 'Could not load models.'); + const data = response.data as { + models?: { + id: string; + name?: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; + }[]; + }; + return (data.models ?? []).map((m) => ({ + id: m.id, + name: m.name ?? m.id, + provider: m.provider, + contextWindow: m.contextWindow, + reasoning: m.reasoning, + })); + } finally { + if (!existing) runtime.dispose(); + } + }); + handle('session:model', async (id, provider, modelId) => { + const runtime = sessions.get(id); + if (!runtime) throw new Error('Resume the session to change its model.'); + const result = await runtime.command({ + type: 'set_model', + provider, + modelId, + }); + if (!result.success) + throw new Error(result.error ?? 'Could not change model.'); + }); +} + +export async function sessionDefaults(cwd: string): Promise { + try { + const all = JSON.parse( + await fs.readFile( + path.join(app.getPath('userData'), 'session-defaults.json'), + 'utf8', + ), + ); + return validateDefaults(all[cwd] ?? DEFAULTS); + } catch { + return DEFAULTS; + } +} diff --git a/src/main/workbench.ts b/src/main/workbench.ts new file mode 100644 index 0000000..9b63c94 --- /dev/null +++ b/src/main/workbench.ts @@ -0,0 +1,366 @@ +import { execFile } from 'child_process'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { promisify } from 'util'; +import { SESSIONS_ROOT } from './sessions'; + +const exec = promisify(execFile); +const OMIT = new Set([ + '.git', + 'node_modules', + 'dist', + 'out', + '.next', + '.venv', + 'coverage', +]); +const MAX_TEXT = 1024 * 1024; + +export type GitChange = { + path: string; + additions: number; + deletions: number; + staged: boolean; + untracked: boolean; +}; +export type WorkspaceDetails = { + branch: string; + changes: GitChange[]; + files: string[]; + gitError?: string; +}; +export type Defaults = { model: string; provider: string; thinking: string }; +export type SkillInfo = { + name: string; + description: string; + file: string; + source: 'Workspace' | 'Global'; +}; +export const DEFAULTS: Defaults = { + model: '', + provider: '', + thinking: 'medium', +}; + +export function inside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return ( + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +export async function workspacePath( + cwd: string, + file: string, +): Promise { + if (typeof file !== 'string' || !file || path.isAbsolute(file)) + throw new Error('Choose a workspace file.'); + const root = await fs.realpath(cwd); + const target = await fs.realpath(path.resolve(root, file)); + if (!inside(root, target)) throw new Error('File is outside this workspace.'); + return target; +} + +export async function readText(file: string): Promise { + const handle = await fs.open(file, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > MAX_TEXT) + throw new Error('Preview supports text files up to 1 MB.'); + const buffer = await handle.readFile(); + if (buffer.includes(0)) + throw new Error('Binary files cannot be previewed.'); + return buffer.toString('utf8'); + } finally { + await handle.close(); + } +} + +export async function git(cwd: string, args: string[]): Promise { + const { stdout } = await exec( + 'git', + ['--no-pager', '-c', 'core.quotePath=false', ...args], + { + cwd, + timeout: 15_000, + maxBuffer: 8 * MAX_TEXT, + encoding: 'utf8', + }, + ); + return stdout; +} + +export function parseNumstat(text: string, staged: boolean): GitChange[] { + return text + .split('\0') + .filter(Boolean) + .flatMap((record) => { + const match = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/.exec(record); + return match + ? [ + { + path: match[3], + additions: Number(match[1]) || 0, + deletions: Number(match[2]) || 0, + staged, + untracked: false, + }, + ] + : []; + }); +} + +async function listFiles(cwd: string): Promise { + const files: string[] = []; + async function walk(relative: string, depth: number) { + if (depth > 7 || files.length >= 3000) return; + const entries = await fs.readdir(path.join(cwd, relative), { + withFileTypes: true, + }); + entries.sort( + (a, b) => + Number(b.isDirectory()) - Number(a.isDirectory()) || + a.name.localeCompare(b.name), + ); + for (const entry of entries) { + if ( + OMIT.has(entry.name) || + entry.isSymbolicLink() || + files.length >= 3000 + ) + continue; + const name = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(name, depth + 1).catch(() => {}); + else if (entry.isFile()) files.push(name); + } + } + await walk('', 0); + return files; +} + +export async function inspectWorkspace(cwd: string): Promise { + const files = await listFiles(cwd); + try { + const [branch, working, staged, untracked] = await Promise.all([ + git(cwd, ['branch', '--show-current']), + git(cwd, [ + 'diff', + '--no-ext-diff', + '--no-textconv', + '--no-renames', + '--numstat', + '-z', + '--relative', + '--', + '.', + ]), + git(cwd, [ + 'diff', + '--cached', + '--no-ext-diff', + '--no-textconv', + '--no-renames', + '--numstat', + '-z', + '--relative', + '--', + '.', + ]), + git(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), + ]); + return { + branch: branch.trim(), + files, + changes: [ + ...parseNumstat(working, false), + ...parseNumstat(staged, true), + ...untracked + .split('\0') + .filter(Boolean) + .map((file) => ({ + path: file, + additions: 0, + deletions: 0, + staged: false, + untracked: true, + })), + ], + }; + } catch { + return { + branch: '', + changes: [], + files, + gitError: 'Git changes are unavailable for this folder.', + }; + } +} + +export async function filePatch( + cwd: string, + file: string, + staged: boolean, + untracked: boolean, +): Promise { + // Deleted paths cannot be realpathed. Lexical containment still protects Git pathspecs. + if ( + !file || + path.isAbsolute(file) || + !inside(path.resolve(cwd), path.resolve(cwd, file)) + ) + throw new Error('Invalid file path.'); + if (untracked) { + await readText(await workspacePath(cwd, file)); + try { + return await git(cwd, [ + 'diff', + '--no-index', + '--no-ext-diff', + '--no-textconv', + '--', + '/dev/null', + file, + ]); + } catch (error) { + // Git uses exit status 1 to report a valid patch with differences. + const result = error as { code?: number; stdout?: string }; + if (result.code === 1 && typeof result.stdout === 'string') + return result.stdout; + throw error; + } + } + return git(cwd, [ + '--literal-pathspecs', + 'diff', + ...(staged ? ['--cached'] : []), + '--no-ext-diff', + '--no-textconv', + '--relative', + '--', + file, + ]); +} + +export async function sessionPath( + file: string, + sessionsRoot = SESSIONS_ROOT, +): Promise { + const root = await fs.realpath(sessionsRoot); + const target = await fs.realpath(file); + if (!inside(root, target) || !target.endsWith('.jsonl')) + throw new Error('Invalid session file.'); + return target; +} + +export async function readSession( + file: string, + sessionsRoot = SESSIONS_ROOT, +): Promise { + const target = await sessionPath(file, sessionsRoot); + const stat = await fs.stat(target); + if (stat.size > 16 * MAX_TEXT) + throw new Error( + 'This session is too large to preview. Resume it to continue.', + ); + const records: { + id?: string; + parentId?: string; + type?: string; + message?: unknown; + }[] = []; + for (const line of (await fs.readFile(target, 'utf8')).split('\n')) { + try { + const record = JSON.parse(line); + if ( + record && + typeof record === 'object' && + !Array.isArray(record) && + typeof record.id === 'string' + ) + records.push(record); + } catch { + /* Partial final writes are expected. */ + } + } + // Follow the current branch rather than mixing messages from abandoned branches. + const byId = new Map(records.filter((r) => r.id).map((r) => [r.id, r])); + let current = records.at(-1); + const branch: unknown[] = []; + const seen = new Set(); + while (current) { + if (current.id && seen.has(current.id)) break; + if (current.id) seen.add(current.id); + if (current.type === 'message' && current.message) + branch.unshift(current.message); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + return branch; +} + +export async function listSkills(cwd: string): Promise { + const result: SkillInfo[] = []; + const seen = new Set(); + for (const [root, source] of [ + [path.join(os.homedir(), '.pi', 'agent', 'skills'), 'Global'], + [path.join(os.homedir(), '.agents', 'skills'), 'Global'], + [path.join(cwd, '.pi', 'skills'), 'Workspace'], + [path.join(cwd, '.agents', 'skills'), 'Workspace'], + ] as const) { + async function walk(dir: string, depth: number) { + if (depth > 4 || result.length >= 500) return; + const real = await fs.realpath(dir); + if (seen.has(real)) return; + seen.add(real); + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + if (result.length >= 500) break; + const file = path.join(dir, entry.name); + try { + if ( + entry.isDirectory() || + (entry.isSymbolicLink() && (await fs.stat(file)).isDirectory()) + ) + await walk(file, depth + 1); + else if (entry.name === 'SKILL.md') { + const text = await readText(file); + const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const field = (name: string) => + frontmatter?.[1] + .match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] + .replace(/^["']|["']$/g, '') ?? ''; + result.push({ + name: field('name') || path.basename(dir), + description: field('description'), + file, + source, + }); + } + } catch { + // A missing link or invalid skill must not hide its siblings. + } + } + } + await walk(root, 0).catch(() => {}); + } + return result; +} + +export function validateDefaults(value: unknown): Defaults { + const v = value as Partial | null; + if ( + !v || + typeof v.model !== 'string' || + typeof v.provider !== 'string' || + !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes( + v.thinking ?? '', + ) + ) + throw new Error('Invalid session defaults.'); + return { + model: v.model.slice(0, 200), + provider: v.provider.slice(0, 100), + thinking: v.thinking!, + }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 012ceff..bdfca0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,22 +13,46 @@ contextBridge.exposeInMainWorld('pidex', { }, workspaces: () => ipcRenderer.invoke('pidex:workspaces'), addWorkspace: () => ipcRenderer.invoke('pidex:workspace:add'), + inspectWorkspace: (cwd: string) => + ipcRenderer.invoke('pidex:workspace:inspect', cwd), + readFile: (cwd: string, file: string) => + ipcRenderer.invoke('pidex:workspace:read', cwd, file), + filePatch: (cwd: string, file: string, staged: boolean, untracked: boolean) => + ipcRenderer.invoke('pidex:workspace:patch', cwd, file, staged, untracked), + gitAction: (cwd: string, action: string, file?: string) => + ipcRenderer.invoke('pidex:workspace:git', cwd, action, file), + previewSession: (file: string) => + ipcRenderer.invoke('pidex:session:preview', file), + skills: (cwd: string) => ipcRenderer.invoke('pidex:workspace:skills', cwd), + openProject: (cwd: string) => ipcRenderer.invoke('pidex:workspace:open', cwd), + attachFiles: () => ipcRenderer.invoke('pidex:workspace:attach'), + settings: (cwd: string) => ipcRenderer.invoke('pidex:settings:get', cwd), + saveSettings: (cwd: string, value: unknown) => + ipcRenderer.invoke('pidex:settings:save', cwd, value), + manageProviders: () => ipcRenderer.invoke('pidex:settings:providers'), + models: (cwd: string) => ipcRenderer.invoke('pidex:models:list', cwd), + setModel: (id: string, provider: string, model: string) => + ipcRenderer.invoke('pidex:session:model', id, provider, model), sessions: (dirName: string) => ipcRenderer.invoke('pidex:sessions', dirName), - openSession: (req: { cwd: string; sessionFile?: string }) => + openSession: (req: { cwd: string; sessionFile?: string; fork?: boolean }) => ipcRenderer.invoke('pidex:session/open', req), sendPrompt: (req: { sessionId: string; text: string; streaming: boolean }) => ipcRenderer.invoke('pidex:session/send', req), - abortSession: (sessionId: string) => ipcRenderer.invoke('pidex:session/abort', sessionId), + abortSession: (sessionId: string) => + ipcRenderer.invoke('pidex:session/abort', sessionId), sessionCommands: (sessionId: string) => ipcRenderer.invoke('pidex:session/commands', sessionId), - closeSession: (sessionId: string) => ipcRenderer.invoke('pidex:session/close', sessionId), + closeSession: (sessionId: string) => + ipcRenderer.invoke('pidex:session/close', sessionId), onSessionEvent: (cb: (payload: SessionEventPayload) => void) => { - const listener = (_event: unknown, payload: SessionEventPayload) => cb(payload); + const listener = (_event: unknown, payload: SessionEventPayload) => + cb(payload); ipcRenderer.on('pidex:session:event', listener); return () => ipcRenderer.removeListener('pidex:session:event', listener); }, onSessionExit: (cb: (payload: { sessionId: string }) => void) => { - const listener = (_event: unknown, payload: { sessionId: string }) => cb(payload); + const listener = (_event: unknown, payload: { sessionId: string }) => + cb(payload); ipcRenderer.on('pidex:session:exit', listener); return () => ipcRenderer.removeListener('pidex:session:exit', listener); }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 59988a4..86798d3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,544 +1,736 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { prepare, layout } from '@chenglou/pretext'; -import { PiMark } from './components/PiMark'; +import { useEffect, useRef, useState } from 'react'; import { PidexMark } from './components/PidexMark'; -import { FigureFrame } from './components/FigureFrame'; +import welcomeMark from './assets/workbench/welcome-mark.svg'; import { BracketButton } from './components/BracketButton'; -import { Markdown } from './components/Markdown'; -import { applyEvent, entriesFromMessages } from './timeline'; -import type { Entry } from './timeline'; -import type { SessionInfo, SlashCommand, WorkspaceInfo } from './api'; - -type SessionTab = { - runtimeId: string; - cwd: string; - workspaceName: string; - title: string; - model: string; - entries: Entry[]; - commands: SlashCommand[]; - running: boolean; - exited: boolean; -}; - -const BODY_FONT = '450 15px Newsreader, Georgia, serif'; - -function timeAgo(iso: string): string { - const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); - if (seconds < 60) return 'now'; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; - return `${Math.floor(seconds / 86400)}d`; -} - -function ToolOutput({ text }: { text: string }) { - const lines = text.split('\n'); - const shown = lines.slice(0, 14); - return ( - <> - {shown.map((line, i) => ( -
- {line} -
- ))} - {lines.length > shown.length ? ( -
… {lines.length - shown.length} more lines
- ) : null} - - ); -} +import { + Composer, + EmptyState, + Icon, + NavItem, + SearchField, + Transcript, +} from './components/WorkbenchControls'; +import { + ChangesView, + FilesView, + SettingsView, + SkillsView, +} from './components/WorkbenchViews'; +import { CommandPalette } from './components/CommandPalette'; +import { timeAgo, useWorkbench } from './useWorkbench'; function App() { - const [workspaces, setWorkspaces] = useState([]); - const [wsSessions, setWsSessions] = useState>({}); - const [expanded, setExpanded] = useState(null); - const [tabs, setTabs] = useState([]); - const [activeId, setActiveId] = useState(null); - const [draft, setDraft] = useState(''); - const [opening, setOpening] = useState(false); - const [openError, setOpenError] = useState(null); - const [slashIndex, setSlashIndex] = useState(0); - const [fontReady, setFontReady] = useState(false); + const b = useWorkbench(); + const { api, ws, active, view, details, sessions, preview } = b; + const [sidebar, setSidebar] = useState(true); + const [terminal, setTerminal] = useState(false); + const [palette, setPalette] = useState(false); + const [sessionQuery, setSessionQuery] = useState(''); + const [allHistory, setAllHistory] = useState(false); + const [paletteFile, setPaletteFile] = useState(''); + const [resetSettings, setResetSettings] = useState(0); const timelineRef = useRef(null); - const bootstrapped = useRef(false); - const openSessionRef = useRef<(ws: WorkspaceInfo, info?: SessionInfo) => Promise>( - async () => {}, - ); - - const activeTab = tabs.find(t => t.runtimeId === activeId) ?? null; - - useEffect(() => { - window.pidex.workspaces().then(async list => { - setWorkspaces(list); - const entries = await Promise.all( - list.map(ws => window.pidex.sessions(ws.dirName).then(s => [ws.dirName, s] as const)), - ); - const map: Record = {}; - for (const [dirName, s] of entries) map[dirName] = s; - setWsSessions(map); - const first = list[0]; - if (first && !bootstrapped.current) { - bootstrapped.current = true; - setExpanded(first.dirName); - const firstSession = map[first.dirName]?.[0]; - if (firstSession) openSessionRef.current(first, firstSession); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); useEffect(() => { - const offEvent = window.pidex.onSessionEvent(({ sessionId, event }) => { - setTabs(prev => - prev.map(tab => { - if (tab.runtimeId !== sessionId) return tab; - const { entries, running } = applyEvent(tab.entries, event); - return { ...tab, entries, running: running ?? tab.running }; - }), - ); - }); - const offExit = window.pidex.onSessionExit(({ sessionId }) => { - setTabs(prev => - prev.map(tab => - tab.runtimeId === sessionId ? { ...tab, exited: true, running: false } : tab, - ), - ); - }); - return () => { - offEvent(); - offExit(); - }; - }, []); - - useEffect(() => { - timelineRef.current?.scrollTo({ top: timelineRef.current.scrollHeight }); - }, [activeTab?.entries.length, activeTab?.entries[activeTab.entries.length - 1]]); - + timelineRef.current?.scrollTo?.({ top: timelineRef.current.scrollHeight }); + }, [active?.entries, preview?.entries, view]); useEffect(() => { - let cancel = false; - const fonts = document.fonts; - if (!fonts?.load) { - setFontReady(true); - return; - } - fonts.load(BODY_FONT).then( - () => { - if (!cancel) setFontReady(true); - }, - () => { - if (!cancel) setFontReady(true); - }, - ); - return () => { - cancel = true; - }; - }, []); - - const measuredRows = useMemo(() => { - if (!activeTab || !fontReady) return 0; - let rows = 0; - for (const entry of activeTab.entries) { - if (entry.kind === 'tool') continue; - const text = entry.kind === 'assistant' ? entry.text || entry.thinking : entry.text; - if (!text) continue; - const prepared = prepare(text, BODY_FONT); - rows += layout(prepared, 780, 24).lineCount; - } - return rows; - }, [activeTab, fontReady]); - - const expandWorkspace = async (ws: WorkspaceInfo) => { - const next = expanded === ws.dirName ? null : ws.dirName; - setExpanded(next); - if (!next) return; - let sessions = wsSessions[next]; - if (!sessions) { - sessions = await window.pidex.sessions(next); - setWsSessions(prev => ({ ...prev, [next]: sessions })); - } - if (sessions[0]) openSession(ws, sessions[0]); - }; - - const addWorkspace = async () => { - const result = await window.pidex.addWorkspace(); - if (result.error || !result.cwd) return; - const list = await window.pidex.workspaces(); - setWorkspaces(list); - const added = list.find(w => w.cwd === result.cwd); - if (added) { - setExpanded(added.dirName); - setWsSessions(prev => ({ ...prev, [added.dirName]: [] })); - } - }; - - const openSession = async (ws: WorkspaceInfo, info?: SessionInfo) => { - const existing = tabs.find( - t => t.cwd === ws.cwd && t.title === (info?.title ?? 'new session'), - ); - if (existing) { - setActiveId(existing.runtimeId); - return; - } - setOpening(true); - setOpenError(null); - try { - const result = await window.pidex.openSession({ - cwd: ws.cwd, - sessionFile: info?.file, - }); - if (result.error || !result.sessionId) { - setOpenError(result.error ?? 'Could not open the session.'); - return; + const onKey = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey)) return; + if (event.key.toLowerCase() === 'k') { + event.preventDefault(); + setPalette((p) => !p); } - const model = - result.state?.model?.id ?? result.state?.model?.name ?? info?.model ?? 'default'; - const { commands } = await window.pidex.sessionCommands(result.sessionId); - const tab: SessionTab = { - runtimeId: result.sessionId, - cwd: ws.cwd, - workspaceName: ws.name, - title: info?.title ?? 'new session', - model, - entries: entriesFromMessages(result.messages ?? []), - commands, - running: Boolean(result.state?.isStreaming), - exited: false, - }; - setTabs(prev => [...prev, tab]); - setActiveId(tab.runtimeId); - } finally { - setOpening(false); - } - }; - openSessionRef.current = openSession; - - const closeTab = (runtimeId: string) => { - window.pidex.closeSession(runtimeId); - setTabs(prev => { - const next = prev.filter(t => t.runtimeId !== runtimeId); - if (activeId === runtimeId) setActiveId(next[next.length - 1]?.runtimeId ?? null); - return next; - }); - }; - - const send = async (raw?: string) => { - const text = (raw ?? draft).trim(); - if (!text || !activeTab || activeTab.exited) return; - const echo: Entry = { key: `local-${Date.now()}`, kind: 'user', text }; - setTabs(prev => - prev.map(t => (t.runtimeId === activeTab.runtimeId ? { ...t, entries: [...t.entries, echo] } : t)), - ); - setDraft(''); - setSlashIndex(0); - await window.pidex.sendPrompt({ - sessionId: activeTab.runtimeId, - text, - streaming: activeTab.running, - }); - }; - - const slashQuery = draft.startsWith('/') ? draft.slice(1) : null; - const slashMatches = slashQuery !== null && activeTab - ? activeTab.commands - .filter(c => c.name.toLowerCase().includes(slashQuery.toLowerCase())) - .slice(0, 8) - : []; - const slashOpen = slashQuery !== null && slashMatches.length > 0; - - const onComposerKey = (e: React.KeyboardEvent) => { - if (slashOpen) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSlashIndex(i => (i + 1) % slashMatches.length); - return; + if (event.key === ',') { + event.preventDefault(); + b.navigate('settings'); } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSlashIndex(i => (i - 1 + slashMatches.length) % slashMatches.length); - return; + if (event.key.toLowerCase() === 'h' && event.shiftKey) { + event.preventDefault(); + b.navigate('history'); } - if (e.key === 'Tab') { - e.preventDefault(); - const cmd = slashMatches[Math.min(slashIndex, slashMatches.length - 1)]; - setDraft(`/${cmd.name} `); - setSlashIndex(0); - return; + if (event.key.toLowerCase() === 'b') { + event.preventDefault(); + setSidebar((v) => !v); } - if (e.key === 'Escape') { - e.preventDefault(); - setDraft(''); - setSlashIndex(0); - return; + if (event.key.toLowerCase() === 'o') { + event.preventDefault(); + void b.addWorkspace(); } - if (e.key === 'Enter') { - e.preventDefault(); - const cmd = slashMatches[Math.min(slashIndex, slashMatches.length - 1)]; - send(`/${cmd.name}`); - return; + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); + + const composer = (placeholder?: string, label?: string) => ( + active && b.run(() => api.abortSession(active.runtimeId))} + onAttach={b.attach} + model={b.currentModel} + models={b.models} + onModel={b.changeModel} + running={view === 'session' && active?.running} + disabled={ + !ws || (view === 'session' && active?.exited) || b.previewLoading } - } - if (e.key === 'Enter') send(); + busy={b.busy} + placeholder={placeholder} + label={label} + commands={active?.commands ?? []} + modelDisabled={view === 'history'} + allowDefaultModel={view === 'new' || !active} + /> + ); + const filteredSessions = sessions.filter((s) => + `${s.title} ${s.model}`.toLowerCase().includes(sessionQuery.toLowerCase()), + ); + const title = { + new: 'Workspace', + session: active?.title ?? 'Session', + changes: 'Changes review', + history: 'Session history', + settings: 'Models & access', + skills: 'Skills', + files: 'Files & preview', + }[view]; + const branch = details.branch || ws?.branch || 'local'; + const context = `${ws?.name ?? 'pidex'} / ${branch} / ${view === 'history' ? `${sessions.length} sessions` : view === 'changes' ? `${details.changes.length} changes` : view === 'settings' ? 'workspace defaults' : 'local workspace'}`; + const live = b.tabs.filter((t) => t.cwd === ws?.cwd && !t.exited); + const exportSession = () => { + if (!preview) return; + const text = preview.entries + .map((e) => + e.kind === 'tool' + ? `## ${e.tool}\n\n${e.output}` + : `## ${e.kind}\n\n${e.text}`, + ) + .join('\n\n'); + const url = URL.createObjectURL( + new Blob([text], { type: 'text/markdown' }), + ); + const a = document.createElement('a'); + a.href = url; + a.download = 'pidex-session.md'; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); }; return ( -
- - -
- - -
- {activeTab ? ( + Settings + +

+ {active?.running ? 'PI WORKING / LOCAL' : 'LOCAL WORKSPACE'} +

+ + )} +
+ {b.error && ( +
+ {b.error} + +
+ )} + {!ws ? ( +
+ +

Welcome to pidex

+

+ Pick a project folder to get started. Your conversations and + settings stay local. +

+
+ Open project +
~/projects /
+ void b.addWorkspace()} + disabled={b.loading || !api} + > + {b.loading ? 'Loading…' : 'Browse folder'} + +
+
+ ) : ( <> -
-
-

{activeTab.workspaceName}

- - {activeTab.exited ? 'exited' : activeTab.running ? 'running' : 'idle'} - -
- window.pidex.abortSession(activeTab.runtimeId)}> - Abort - -
-
-
- {activeTab.cwd} - - model {activeTab.model} - - - session {activeTab.title.slice(0, 48)} - +
+
+

{title}

+

{context}

+ {view === 'settings' ? ( + setResetSettings((v) => v + 1)}> + Restore defaults + + ) : view === 'history' ? ( + + Export + + ) : ( + + view === 'new' + ? void b.addWorkspace() + : b.navigate('files') + } + > + {view === 'new' ? 'Open project' : 'Browse files'} + + )}
- -
- {activeTab.entries.map(entry => { - if (entry.kind === 'tool') { - return ( - +
+
+ +
+

Start a session

+

Bring a task. Pi works in your workspace.

+
+
+
+ Work in + + {ws.cwd} +
+ {composer()} +
+ + +
- ); - })} -
- -
- {slashOpen ? ( -
- {slashMatches.map((cmd, i) => ( +
+ Continue a session - ))} +
+
+ {sessions.slice(0, 2).map((s) => ( + + ))} + {!sessions.length && ( +

+ No saved sessions yet. Start with a task above. +

+ )} +
- ) : null} -
- › - { - setDraft(e.target.value); - setSlashIndex(0); - }} - onKeyDown={onComposerKey} - placeholder={ - activeTab.exited - ? 'session exited' - : activeTab.running - ? `steer ${activeTab.workspaceName}…` - : `prompt ${activeTab.workspaceName}…` - } - disabled={activeTab.exited} - spellCheck={false} - />
-
- {activeTab.running ? 'enter to steer · / for commands' : 'enter to send · / for commands'} - {activeTab.model} + )} + {view === 'session' && + (active ? ( +
+
+
+ + {!active.entries.length && ( + + Send a task to start working with Pi. + + )} +
+ {composer( + active.exited + ? 'Session exited. Start a new session to continue.' + : active.running + ? 'Add a follow-up while pi works…' + : 'Continue this session…', + )} +
+ +
+ ) : ( + + Select a saved session or start a new one. + + ))} + {view === 'changes' && ( + + )} + {view === 'history' && ( +
+ +
+ {preview ? ( + <> +
+
+

{preview.info.title}

+

+ {preview.info.model || 'Pi'} / saved locally +

+
+ b.navigate('changes')} + > + Review changes + +
+
+ {b.previewLoading ? ( +

+ Loading session… +

+ ) : ( + <> + +
+ + Fork from here + + + Start a new session from this conversation. + +
+ + )} +
+ {composer('Continue this session…', 'Resume')} + + ) : ( + + Saved conversations will appear here. + + )} +
-
+ )} + {view === 'settings' && ( + b.navigate('files')} + /> + )} + {view === 'skills' && ( + + )} + {view === 'files' && ( + + )} - ) : ( -
- -

No session open

-

- Pick a session from the sidebar, or start a new one in the expanded workspace. -

- {openError ?

{openError}

: null} -
+ )} + {terminal && ( +
+
+ Session output + +
+
+                {active?.entries
+                  .filter((e) => e.kind === 'tool' && e.tool === 'bash')
+                  .map((e) =>
+                    e.kind === 'tool' ? `$ ${e.args}\n${e.output}` : '',
+                  )
+                  .join('\n\n') ||
+                  'Shell output from the current Pi session will appear here.'}
+              
+
)}
- -
-
- - {activeTab?.running ? 'agent running' : 'runtime linked'} - - {tabs.length} tabs -
+
+ + {!ws + ? 'RUNTIME IDLE' + : active?.exited + ? 'PI EXITED' + : active?.running + ? 'PI WORKING' + : b.busy + ? 'PI CONNECTING' + : b.modelError && !active + ? 'PI UNAVAILABLE' + : 'PI READY'} + - $ pidex — {measuredRows} rows measured · 0 dom reads + {view === 'history' + ? 'Local session archive' + : `${b.measuredRows} rows measured / ${b.tabs.length} open sessions`}
+ {palette && ( + setPalette(false)} + onWorkspace={b.selectWorkspace} + onSession={b.showSession} + onNavigate={b.navigate} + onFile={(file) => { + setPaletteFile(file); + b.navigate('files'); + }} + onCommand={b.useSkill} + onAddWorkspace={() => void b.addWorkspace()} + /> + )}
); } -export { App }; \ No newline at end of file +export { App }; diff --git a/src/renderer/api.ts b/src/renderer/api.ts index ba6faf8..840fd8f 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -1,3 +1,19 @@ +import type { + Defaults, + GitChange, + SkillInfo, + WorkspaceDetails, +} from '../main/workbench'; + +type Result = { data?: T; error?: string }; +type ModelInfo = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; +}; + type WorkspaceInfo = { dirName: string; name: string; @@ -48,9 +64,43 @@ type PidexApi = { versions: { electron: string; node: string }; workspaces: () => Promise; addWorkspace: () => Promise<{ cwd?: string; error?: string }>; + inspectWorkspace: (cwd: string) => Promise>; + readFile: (cwd: string, file: string) => Promise>; + filePatch: ( + cwd: string, + file: string, + staged: boolean, + untracked: boolean, + ) => Promise>; + gitAction: ( + cwd: string, + action: 'stage' | 'stage-all' | 'unstage' | 'commit', + file?: string, + ) => Promise>; + previewSession: (file: string) => Promise>; + skills: (cwd: string) => Promise>; + openProject: (cwd: string) => Promise>; + attachFiles: () => Promise>; + settings: (cwd: string) => Promise>; + saveSettings: (cwd: string, value: Defaults) => Promise>; + manageProviders: () => Promise>; + models: (cwd: string) => Promise>; + setModel: ( + id: string, + provider: string, + model: string, + ) => Promise>; sessions: (dirName: string) => Promise; - openSession: (req: { cwd: string; sessionFile?: string }) => Promise; - sendPrompt: (req: { sessionId: string; text: string; streaming: boolean }) => Promise<{ success?: boolean; error?: string }>; + openSession: (req: { + cwd: string; + sessionFile?: string; + fork?: boolean; + }) => Promise; + sendPrompt: (req: { + sessionId: string; + text: string; + streaming: boolean; + }) => Promise<{ success?: boolean; error?: string }>; abortSession: (sessionId: string) => Promise; sessionCommands: (sessionId: string) => Promise<{ commands: SlashCommand[] }>; closeSession: (sessionId: string) => Promise; @@ -64,4 +114,20 @@ declare global { } } -export type { WorkspaceInfo, SessionInfo, SessionState, OpenResult, SessionEventPayload, PidexApi, SlashCommand }; +export type { + WorkspaceInfo, + SessionInfo, + SessionState, + OpenResult, + SessionEventPayload, + PidexApi, + SlashCommand, +}; +export type { + Defaults, + GitChange, + SkillInfo, + WorkspaceDetails, + ModelInfo, + Result, +}; diff --git a/src/renderer/assets/fonts/IBMPlexMono-LICENSE.txt b/src/renderer/assets/fonts/IBMPlexMono-LICENSE.txt new file mode 100644 index 0000000..670c6c0 --- /dev/null +++ b/src/renderer/assets/fonts/IBMPlexMono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/renderer/assets/fonts/IBMPlexMono-Regular.ttf b/src/renderer/assets/fonts/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..0c9770d Binary files /dev/null and b/src/renderer/assets/fonts/IBMPlexMono-Regular.ttf differ diff --git a/src/renderer/assets/fonts/JetBrainsMono-LICENSE.txt b/src/renderer/assets/fonts/JetBrainsMono-LICENSE.txt new file mode 100644 index 0000000..821a3da --- /dev/null +++ b/src/renderer/assets/fonts/JetBrainsMono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/renderer/assets/fonts/JetBrainsMono-Variable.ttf b/src/renderer/assets/fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 0000000..aa310be Binary files /dev/null and b/src/renderer/assets/fonts/JetBrainsMono-Variable.ttf differ diff --git a/src/renderer/assets/workbench/activity-terminal.svg b/src/renderer/assets/workbench/activity-terminal.svg new file mode 100644 index 0000000..99620b0 --- /dev/null +++ b/src/renderer/assets/workbench/activity-terminal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/appearance.svg b/src/renderer/assets/workbench/appearance.svg new file mode 100644 index 0000000..be516ec --- /dev/null +++ b/src/renderer/assets/workbench/appearance.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/changes.svg b/src/renderer/assets/workbench/changes.svg new file mode 100644 index 0000000..0cdd65a --- /dev/null +++ b/src/renderer/assets/workbench/changes.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/renderer/assets/workbench/check.svg b/src/renderer/assets/workbench/check.svg new file mode 100644 index 0000000..4275972 --- /dev/null +++ b/src/renderer/assets/workbench/check.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/chevron-down.svg b/src/renderer/assets/workbench/chevron-down.svg new file mode 100644 index 0000000..9ebf018 --- /dev/null +++ b/src/renderer/assets/workbench/chevron-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/file-code.svg b/src/renderer/assets/workbench/file-code.svg new file mode 100644 index 0000000..4c8afc9 --- /dev/null +++ b/src/renderer/assets/workbench/file-code.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/assets/workbench/file.svg b/src/renderer/assets/workbench/file.svg new file mode 100644 index 0000000..bcbe21c --- /dev/null +++ b/src/renderer/assets/workbench/file.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/assets/workbench/folder-open.svg b/src/renderer/assets/workbench/folder-open.svg new file mode 100644 index 0000000..ff92c86 --- /dev/null +++ b/src/renderer/assets/workbench/folder-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/folder.svg b/src/renderer/assets/workbench/folder.svg new file mode 100644 index 0000000..8bfd171 --- /dev/null +++ b/src/renderer/assets/workbench/folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/message-square.svg b/src/renderer/assets/workbench/message-square.svg new file mode 100644 index 0000000..4183784 --- /dev/null +++ b/src/renderer/assets/workbench/message-square.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/panel-left.svg b/src/renderer/assets/workbench/panel-left.svg new file mode 100644 index 0000000..745a3fe --- /dev/null +++ b/src/renderer/assets/workbench/panel-left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/paperclip.svg b/src/renderer/assets/workbench/paperclip.svg new file mode 100644 index 0000000..fef973d --- /dev/null +++ b/src/renderer/assets/workbench/paperclip.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/plus.svg b/src/renderer/assets/workbench/plus.svg new file mode 100644 index 0000000..3b88342 --- /dev/null +++ b/src/renderer/assets/workbench/plus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/search.svg b/src/renderer/assets/workbench/search.svg new file mode 100644 index 0000000..9b72762 --- /dev/null +++ b/src/renderer/assets/workbench/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/settings.svg b/src/renderer/assets/workbench/settings.svg new file mode 100644 index 0000000..88bd56a --- /dev/null +++ b/src/renderer/assets/workbench/settings.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/assets/workbench/shield.svg b/src/renderer/assets/workbench/shield.svg new file mode 100644 index 0000000..52f4b23 --- /dev/null +++ b/src/renderer/assets/workbench/shield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/skills.svg b/src/renderer/assets/workbench/skills.svg new file mode 100644 index 0000000..e8c440a --- /dev/null +++ b/src/renderer/assets/workbench/skills.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/assets/workbench/starter-changes.svg b/src/renderer/assets/workbench/starter-changes.svg new file mode 100644 index 0000000..0cdd65a --- /dev/null +++ b/src/renderer/assets/workbench/starter-changes.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/renderer/assets/workbench/starter-search.svg b/src/renderer/assets/workbench/starter-search.svg new file mode 100644 index 0000000..02949fb --- /dev/null +++ b/src/renderer/assets/workbench/starter-search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/terminal.svg b/src/renderer/assets/workbench/terminal.svg new file mode 100644 index 0000000..08c86c2 --- /dev/null +++ b/src/renderer/assets/workbench/terminal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/assets/workbench/welcome-mark.svg b/src/renderer/assets/workbench/welcome-mark.svg new file mode 100644 index 0000000..1511fe8 --- /dev/null +++ b/src/renderer/assets/workbench/welcome-mark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/renderer/components/BracketButton.tsx b/src/renderer/components/BracketButton.tsx index 0c36dbd..271c16d 100644 --- a/src/renderer/components/BracketButton.tsx +++ b/src/renderer/components/BracketButton.tsx @@ -4,12 +4,22 @@ type BracketButtonProps = ButtonHTMLAttributes & { primary?: boolean; }; -function BracketButton({ primary = false, className, children, ...rest }: BracketButtonProps) { +function BracketButton({ + primary = false, + className, + children, + ...rest +}: BracketButtonProps) { const classes = ['ds-button', primary ? 'ds-button--primary' : '', className] .filter(Boolean) .join(' '); return ( - ); diff --git a/src/renderer/components/CommandPalette.tsx b/src/renderer/components/CommandPalette.tsx new file mode 100644 index 0000000..a429c89 --- /dev/null +++ b/src/renderer/components/CommandPalette.tsx @@ -0,0 +1,293 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import { Icon } from './WorkbenchControls'; +import type { SessionInfo, SlashCommand, WorkspaceInfo } from '../api'; +import type { View } from '../useWorkbench'; + +type Category = 'Commands' | 'Projects' | 'Sessions' | 'Files' | 'Skills'; +type Item = { + id: string; + title: string; + detail: string; + category: Category; + run: () => void; +}; + +export function CommandPalette({ + workspaces, + sessions, + files, + commands, + onClose, + onWorkspace, + onSession, + onNavigate, + onFile, + onCommand, + onAddWorkspace, +}: { + workspaces: WorkspaceInfo[]; + sessions: SessionInfo[]; + files: string[]; + commands: SlashCommand[]; + onClose: () => void; + onWorkspace: (ws: WorkspaceInfo) => void; + onSession: (session: SessionInfo) => void; + onNavigate: (view: View) => void; + onFile: (file: string) => void; + onCommand: (command: string) => void; + onAddWorkspace: () => void; +}) { + const [query, setQuery] = useState(''); + const [category, setCategory] = useState('Commands'); + const [index, setIndex] = useState(0); + const dialog = useRef(null); + const input = useRef(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + input.current?.focus(); + return () => previous?.focus(); + }, []); + const items = useMemo( + () => [ + ...( + [ + ['New session', 'new'], + ['Session history', 'history'], + ['Review changes', 'changes'], + ['Browse files', 'files'], + ['Browse skills', 'skills'], + ['Models & access', 'settings'], + ] as const + ).map(([title, view]) => ({ + id: view, + title, + detail: 'Navigate in this workspace', + category: 'Commands' as const, + run: () => onNavigate(view), + })), + { + id: 'open-project', + title: 'Open project', + detail: 'Choose a local folder', + category: 'Commands', + run: onAddWorkspace, + }, + ...commands.map((c) => ({ + id: `command:${c.name}`, + title: `/${c.name}`, + detail: c.description ?? c.source ?? 'Pi command', + category: c.name.startsWith('skill:') + ? ('Skills' as const) + : ('Commands' as const), + run: () => onCommand(`/${c.name} `), + })), + ...workspaces.map((ws) => ({ + id: ws.cwd, + title: ws.name, + detail: ws.cwd, + category: 'Projects' as const, + run: () => onWorkspace(ws), + })), + ...sessions.map((s) => ({ + id: s.id, + title: s.title, + detail: `${s.messageCount} messages / ${s.model || 'Pi'}`, + category: 'Sessions' as const, + run: () => onSession(s), + })), + ...files.map((file) => ({ + id: file, + title: file.split('/').at(-1) ?? file, + detail: file, + category: 'Files' as const, + run: () => onFile(file), + })), + ], + [ + commands, + files, + onAddWorkspace, + onCommand, + onFile, + onNavigate, + onSession, + onWorkspace, + sessions, + workspaces, + ], + ); + const matches = items + .filter( + (item) => + item.category === category && + `${item.title} ${item.detail}` + .toLowerCase() + .includes(query.toLowerCase()), + ) + .slice(0, 80); + const selected = Math.min(index, Math.max(0, matches.length - 1)); + const choose = (item: Item) => { + onClose(); + item.run(); + }; + const keyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + setIndex( + (selected + (e.key === 'ArrowDown' ? 1 : -1) + matches.length) % + Math.max(1, matches.length), + ); + } + if (e.key === 'Enter' && matches[selected]) { + e.preventDefault(); + choose(matches[selected]); + } + if (e.key === 'Tab') { + const focusable = [ + ...(dialog.current?.querySelectorAll('input, button') ?? + []), + ]; + const first = focusable[0]; + const last = focusable.at(-1); + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + }; + useEffect(() => { + dialog.current + ?.querySelector('[role="option"][aria-selected="true"]') + ?.scrollIntoView?.({ block: 'nearest' }); + }, [selected]); + return ( +
{ + if (e.currentTarget === e.target) onClose(); + }} + > +
+
+ ⌘ + › + { + setQuery(e.target.value); + setIndex(0); + }} + role="combobox" + aria-expanded="true" + aria-controls="quick-switch-results" + aria-activedescendant={ + matches[selected] ? `palette-result-${selected}` : undefined + } + /> + +
+
+ {( + ['Commands', 'Projects', 'Sessions', 'Files', 'Skills'] as const + ).map((name) => ( + + ))} +
+
+ {matches.map((item, i) => ( + + ))} + {!matches.length && ( +

+ No matching {category.toLowerCase()}. +

+ )} +
+
+ ↑↓ navigate · enter open · esc close + {matches.length} results +
+
+
+ ); +} diff --git a/src/renderer/components/PierreDiff.tsx b/src/renderer/components/PierreDiff.tsx index 4440abd..0aed08e 100644 --- a/src/renderer/components/PierreDiff.tsx +++ b/src/renderer/components/PierreDiff.tsx @@ -133,4 +133,9 @@ function PierreDiff({ oldFile, newFile, filename, className }: PierreDiffProps) ); } -export { PierreDiff }; \ No newline at end of file +export { PierreDiff }; + +/** Render an existing Git patch without regenerating or truncating its hunks. */ +export function PierrePatch({ patch }: { patch: string }) { + return ; +} diff --git a/src/renderer/components/WorkbenchControls.tsx b/src/renderer/components/WorkbenchControls.tsx new file mode 100644 index 0000000..0d3e3a7 --- /dev/null +++ b/src/renderer/components/WorkbenchControls.tsx @@ -0,0 +1,358 @@ +import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; +import { BracketButton } from './BracketButton'; +import { FigureFrame } from './FigureFrame'; +import { Markdown } from './Markdown'; +import type { Entry } from '../timeline'; +import type { ModelInfo, SlashCommand } from '../api'; + +const icons = import.meta.glob('../assets/workbench/*.svg', { + eager: true, + query: '?url', + import: 'default', +}); +export type IconName = + | 'file-code' + | 'file' + | 'activity-terminal' + | 'starter-changes' + | 'starter-search' + | 'folder-open' + | 'folder' + | 'message-square' + | 'changes' + | 'skills' + | 'settings' + | 'terminal' + | 'paperclip' + | 'check' + | 'search' + | 'panel-left' + | 'chevron-down' + | 'shield' + | 'appearance' + | 'plus'; + +export function Icon({ name }: { name: IconName }) { + return ( + + ); +} + +export function NavItem({ + icon, + children, + detail, + active, + onClick, +}: { + icon: IconName; + children: ReactNode; + detail?: ReactNode; + active?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function SearchField({ + value, + onChange, + placeholder, + label, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; + label: string; +}) { + return ( + + ); +} + +export function Composer({ + draft, + onDraft, + onSend, + onStop, + onAttach, + model, + models, + onModel, + running = false, + disabled = false, + busy = false, + modelDisabled = false, + allowDefaultModel = true, + placeholder, + label = 'Send', + commands = [], +}: { + draft: string; + onDraft: (draft: string) => void; + onSend: (text?: string) => void; + onStop?: () => void; + onAttach: () => void; + model: string; + models: ModelInfo[]; + onModel: (value: string) => void; + running?: boolean; + disabled?: boolean; + busy?: boolean; + placeholder?: string; + label?: string; + commands?: SlashCommand[]; + modelDisabled?: boolean; + allowDefaultModel?: boolean; +}) { + const input = useRef(null); + const [index, setIndex] = useState(0); + const matches = draft.startsWith('/') + ? commands + .filter((c) => + c.name.toLowerCase().includes(draft.slice(1).toLowerCase()), + ) + .slice(0, 8) + : []; + const selected = Math.min(index, Math.max(0, matches.length - 1)); + const keyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + if ( + matches.length && + ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key) + ) { + e.preventDefault(); + if (e.key === 'ArrowDown') setIndex((selected + 1) % matches.length); + if (e.key === 'ArrowUp') + setIndex((selected + matches.length - 1) % matches.length); + if (e.key === 'Tab') { + onDraft(`/${matches[selected].name} `); + setIndex(0); + } + if (e.key === 'Escape') onDraft(''); + return; + } + if (e.key === 'Escape' && running) { + e.preventDefault(); + onStop?.(); + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (!disabled && !busy) + onSend(matches.length ? `/${matches[selected].name}` : undefined); + } + }; + return ( + + {matches.length > 0 && ( +
+ {matches.map((c, i) => ( + + ))} +
+ )} +
+ +