diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..fba7c793 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npx lint-staged +echo "npx lint-staged" diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts deleted file mode 100644 index fe1cdd81..00000000 --- a/src/PixelAgentsViewProvider.ts +++ /dev/null @@ -1,422 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import * as vscode from 'vscode'; - -import { - getProjectDirPath, - launchNewTerminal, - persistAgents, - removeAgent, - restoreAgents, - sendExistingAgents, - sendLayout, -} from './agentManager.js'; -import { - loadCharacterSprites, - loadDefaultLayout, - loadFloorTiles, - loadFurnitureAssets, - loadWallTiles, - sendAssetsToWebview, - sendCharacterSpritesToWebview, - sendFloorTilesToWebview, - sendWallTilesToWebview, -} from './assetLoader.js'; -import { - GLOBAL_KEY_SOUND_ENABLED, - LAYOUT_REVISION_KEY, - WORKSPACE_KEY_AGENT_SEATS, -} from './constants.js'; -import { ensureProjectScan } from './fileWatcher.js'; -import type { LayoutWatcher } from './layoutPersistence.js'; -import { readLayoutFromFile, watchLayoutFile, writeLayoutToFile } from './layoutPersistence.js'; -import type { AgentState } from './types.js'; - -export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { - nextAgentId = { current: 1 }; - nextTerminalIndex = { current: 1 }; - agents = new Map(); - webviewView: vscode.WebviewView | undefined; - - // Per-agent timers - fileWatchers = new Map(); - pollingTimers = new Map>(); - waitingTimers = new Map>(); - jsonlPollTimers = new Map>(); - permissionTimers = new Map>(); - - // /clear detection: project-level scan for new JSONL files - activeAgentId = { current: null as number | null }; - knownJsonlFiles = new Set(); - projectScanTimer = { current: null as ReturnType | null }; - - // Bundled default layout (loaded from assets/default-layout.json) - defaultLayout: Record | null = null; - - // Cross-window layout sync - layoutWatcher: LayoutWatcher | null = null; - - constructor(private readonly context: vscode.ExtensionContext) {} - - private get extensionUri(): vscode.Uri { - return this.context.extensionUri; - } - - private get webview(): vscode.Webview | undefined { - return this.webviewView?.webview; - } - - private persistAgents = (): void => { - persistAgents(this.agents, this.context); - }; - - resolveWebviewView(webviewView: vscode.WebviewView) { - this.webviewView = webviewView; - webviewView.webview.options = { enableScripts: true }; - webviewView.webview.html = getWebviewContent(webviewView.webview, this.extensionUri); - - webviewView.webview.onDidReceiveMessage(async (message) => { - if (message.type === 'openClaude') { - await launchNewTerminal( - this.nextAgentId, - this.nextTerminalIndex, - this.agents, - this.activeAgentId, - this.knownJsonlFiles, - this.fileWatchers, - this.pollingTimers, - this.waitingTimers, - this.permissionTimers, - this.jsonlPollTimers, - this.projectScanTimer, - this.webview, - this.persistAgents, - message.folderPath as string | undefined, - ); - } else if (message.type === 'focusAgent') { - const agent = this.agents.get(message.id); - if (agent) { - agent.terminalRef.show(); - } - } else if (message.type === 'closeAgent') { - const agent = this.agents.get(message.id); - if (agent) { - agent.terminalRef.dispose(); - } - } else if (message.type === 'saveAgentSeats') { - // Store seat assignments in a separate key (never touched by persistAgents) - console.log(`[Pixel Agents] saveAgentSeats:`, JSON.stringify(message.seats)); - this.context.workspaceState.update(WORKSPACE_KEY_AGENT_SEATS, message.seats); - } else if (message.type === 'saveLayout') { - this.layoutWatcher?.markOwnWrite(); - writeLayoutToFile(message.layout as Record); - } else if (message.type === 'setSoundEnabled') { - this.context.globalState.update(GLOBAL_KEY_SOUND_ENABLED, message.enabled); - } else if (message.type === 'webviewReady') { - restoreAgents( - this.context, - this.nextAgentId, - this.nextTerminalIndex, - this.agents, - this.knownJsonlFiles, - this.fileWatchers, - this.pollingTimers, - this.waitingTimers, - this.permissionTimers, - this.jsonlPollTimers, - this.projectScanTimer, - this.activeAgentId, - this.webview, - this.persistAgents, - ); - // Send persisted settings to webview - const soundEnabled = this.context.globalState.get(GLOBAL_KEY_SOUND_ENABLED, true); - this.webview?.postMessage({ type: 'settingsLoaded', soundEnabled }); - - // Send workspace folders to webview (only when multi-root) - const wsFolders = vscode.workspace.workspaceFolders; - if (wsFolders && wsFolders.length > 1) { - this.webview?.postMessage({ - type: 'workspaceFolders', - folders: wsFolders.map((f) => ({ name: f.name, path: f.uri.fsPath })), - }); - } - - // Ensure project scan runs even with no restored agents (to adopt external terminals) - const projectDir = getProjectDirPath(); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - console.log('[Extension] workspaceRoot:', workspaceRoot); - console.log('[Extension] projectDir:', projectDir); - if (projectDir) { - ensureProjectScan( - projectDir, - this.knownJsonlFiles, - this.projectScanTimer, - this.activeAgentId, - this.nextAgentId, - this.agents, - this.fileWatchers, - this.pollingTimers, - this.waitingTimers, - this.permissionTimers, - this.webview, - this.persistAgents, - ); - - // Load furniture assets BEFORE sending layout - (async () => { - try { - console.log('[Extension] Loading furniture assets...'); - const extensionPath = this.extensionUri.fsPath; - console.log('[Extension] extensionPath:', extensionPath); - - // Check bundled location first: extensionPath/dist/assets/ - const bundledAssetsDir = path.join(extensionPath, 'dist', 'assets'); - let assetsRoot: string | null = null; - if (fs.existsSync(bundledAssetsDir)) { - console.log('[Extension] Found bundled assets at dist/'); - assetsRoot = path.join(extensionPath, 'dist'); - } else if (workspaceRoot) { - // Fall back to workspace root (development or external assets) - console.log('[Extension] Trying workspace for assets...'); - assetsRoot = workspaceRoot; - } - - if (!assetsRoot) { - console.log('[Extension] ⚠️ No assets directory found'); - if (this.webview) { - sendLayout(this.context, this.webview, this.defaultLayout); - this.startLayoutWatcher(); - } - return; - } - - console.log('[Extension] Using assetsRoot:', assetsRoot); - - // Load bundled default layout - this.defaultLayout = loadDefaultLayout(assetsRoot); - - // Load character sprites - const charSprites = await loadCharacterSprites(assetsRoot); - if (charSprites && this.webview) { - console.log('[Extension] Character sprites loaded, sending to webview'); - sendCharacterSpritesToWebview(this.webview, charSprites); - } - - // Load floor tiles - const floorTiles = await loadFloorTiles(assetsRoot); - if (floorTiles && this.webview) { - console.log('[Extension] Floor tiles loaded, sending to webview'); - sendFloorTilesToWebview(this.webview, floorTiles); - } - - // Load wall tiles - const wallTiles = await loadWallTiles(assetsRoot); - if (wallTiles && this.webview) { - console.log('[Extension] Wall tiles loaded, sending to webview'); - sendWallTilesToWebview(this.webview, wallTiles); - } - - const assets = await loadFurnitureAssets(assetsRoot); - if (assets && this.webview) { - console.log('[Extension] ✅ Assets loaded, sending to webview'); - sendAssetsToWebview(this.webview, assets); - } - } catch (err) { - console.error('[Extension] ❌ Error loading assets:', err); - } - // Always send saved layout (or null for default) - if (this.webview) { - console.log('[Extension] Sending saved layout'); - sendLayout(this.context, this.webview, this.defaultLayout); - this.startLayoutWatcher(); - } - })(); - } else { - // No project dir — still try to load floor/wall tiles, then send saved layout - (async () => { - try { - const ep = this.extensionUri.fsPath; - const bundled = path.join(ep, 'dist', 'assets'); - if (fs.existsSync(bundled)) { - const distRoot = path.join(ep, 'dist'); - this.defaultLayout = loadDefaultLayout(distRoot); - const cs = await loadCharacterSprites(distRoot); - if (cs && this.webview) { - sendCharacterSpritesToWebview(this.webview, cs); - } - const ft = await loadFloorTiles(distRoot); - if (ft && this.webview) { - sendFloorTilesToWebview(this.webview, ft); - } - const wt = await loadWallTiles(distRoot); - if (wt && this.webview) { - sendWallTilesToWebview(this.webview, wt); - } - } - } catch { - /* ignore */ - } - if (this.webview) { - sendLayout(this.context, this.webview, this.defaultLayout); - this.startLayoutWatcher(); - } - })(); - } - sendExistingAgents(this.agents, this.context, this.webview); - } else if (message.type === 'openSessionsFolder') { - const projectDir = getProjectDirPath(); - if (projectDir && fs.existsSync(projectDir)) { - vscode.env.openExternal(vscode.Uri.file(projectDir)); - } - } else if (message.type === 'exportLayout') { - const layout = readLayoutFromFile(); - if (!layout) { - vscode.window.showWarningMessage('Pixel Agents: No saved layout to export.'); - return; - } - const uri = await vscode.window.showSaveDialog({ - filters: { 'JSON Files': ['json'] }, - defaultUri: vscode.Uri.file(path.join(os.homedir(), 'pixel-agents-layout.json')), - }); - if (uri) { - fs.writeFileSync(uri.fsPath, JSON.stringify(layout, null, 2), 'utf-8'); - vscode.window.showInformationMessage('Pixel Agents: Layout exported successfully.'); - } - } else if (message.type === 'importLayout') { - const uris = await vscode.window.showOpenDialog({ - filters: { 'JSON Files': ['json'] }, - canSelectMany: false, - }); - if (!uris || uris.length === 0) return; - try { - const raw = fs.readFileSync(uris[0].fsPath, 'utf-8'); - const imported = JSON.parse(raw) as Record; - if (imported.version !== 1 || !Array.isArray(imported.tiles)) { - vscode.window.showErrorMessage('Pixel Agents: Invalid layout file.'); - return; - } - this.layoutWatcher?.markOwnWrite(); - writeLayoutToFile(imported); - this.webview?.postMessage({ type: 'layoutLoaded', layout: imported }); - vscode.window.showInformationMessage('Pixel Agents: Layout imported successfully.'); - } catch { - vscode.window.showErrorMessage('Pixel Agents: Failed to read or parse layout file.'); - } - } - }); - - vscode.window.onDidChangeActiveTerminal((terminal) => { - this.activeAgentId.current = null; - if (!terminal) return; - for (const [id, agent] of this.agents) { - if (agent.terminalRef === terminal) { - this.activeAgentId.current = id; - webviewView.webview.postMessage({ type: 'agentSelected', id }); - break; - } - } - }); - - vscode.window.onDidCloseTerminal((closed) => { - for (const [id, agent] of this.agents) { - if (agent.terminalRef === closed) { - if (this.activeAgentId.current === id) { - this.activeAgentId.current = null; - } - removeAgent( - id, - this.agents, - this.fileWatchers, - this.pollingTimers, - this.waitingTimers, - this.permissionTimers, - this.jsonlPollTimers, - this.persistAgents, - ); - webviewView.webview.postMessage({ type: 'agentClosed', id }); - } - } - }); - } - - /** Export current saved layout as a versioned default-layout-{N}.json (dev utility) */ - exportDefaultLayout(): void { - const layout = readLayoutFromFile(); - if (!layout) { - vscode.window.showWarningMessage('Pixel Agents: No saved layout found.'); - return; - } - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) { - vscode.window.showErrorMessage('Pixel Agents: No workspace folder found.'); - return; - } - const assetsDir = path.join(workspaceRoot, 'webview-ui', 'public', 'assets'); - - // Find the next revision number - let maxRevision = 0; - if (fs.existsSync(assetsDir)) { - for (const file of fs.readdirSync(assetsDir)) { - const match = /^default-layout-(\d+)\.json$/.exec(file); - if (match) { - maxRevision = Math.max(maxRevision, parseInt(match[1], 10)); - } - } - } - const nextRevision = maxRevision + 1; - layout[LAYOUT_REVISION_KEY] = nextRevision; - - const targetPath = path.join(assetsDir, `default-layout-${nextRevision}.json`); - const json = JSON.stringify(layout, null, 2); - fs.writeFileSync(targetPath, json, 'utf-8'); - vscode.window.showInformationMessage( - `Pixel Agents: Default layout exported as revision ${nextRevision} to ${targetPath}`, - ); - } - - private startLayoutWatcher(): void { - if (this.layoutWatcher) return; - this.layoutWatcher = watchLayoutFile((layout) => { - console.log('[Pixel Agents] External layout change — pushing to webview'); - this.webview?.postMessage({ type: 'layoutLoaded', layout }); - }); - } - - dispose() { - this.layoutWatcher?.dispose(); - this.layoutWatcher = null; - for (const id of [...this.agents.keys()]) { - removeAgent( - id, - this.agents, - this.fileWatchers, - this.pollingTimers, - this.waitingTimers, - this.permissionTimers, - this.jsonlPollTimers, - this.persistAgents, - ); - } - if (this.projectScanTimer.current) { - clearInterval(this.projectScanTimer.current); - this.projectScanTimer.current = null; - } - } -} - -export function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri): string { - const distPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview'); - const indexPath = vscode.Uri.joinPath(distPath, 'index.html').fsPath; - - let html = fs.readFileSync(indexPath, 'utf-8'); - - html = html.replace(/(href|src)="\.\/([^"]+)"/g, (_match, attr, filePath) => { - const fileUri = vscode.Uri.joinPath(distPath, filePath); - const webviewUri = webview.asWebviewUri(fileUri); - return `${attr}="${webviewUri}"`; - }); - - return html; -} diff --git a/src/agentManager.ts b/src/agentManager.ts deleted file mode 100644 index f7c17a9d..00000000 --- a/src/agentManager.ts +++ /dev/null @@ -1,408 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import * as vscode from 'vscode'; - -import { - JSONL_POLL_INTERVAL_MS, - TERMINAL_NAME_PREFIX, - WORKSPACE_KEY_AGENT_SEATS, - WORKSPACE_KEY_AGENTS, -} from './constants.js'; -import { ensureProjectScan, readNewLines, startFileWatching } from './fileWatcher.js'; -import { migrateAndLoadLayout } from './layoutPersistence.js'; -import { cancelPermissionTimer, cancelWaitingTimer } from './timerManager.js'; -import type { AgentState, PersistedAgent } from './types.js'; - -export function getProjectDirPath(cwd?: string): string | null { - const workspacePath = cwd || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspacePath) return null; - const dirName = workspacePath.replace(/[^a-zA-Z0-9-]/g, '-'); - const projectDir = path.join(os.homedir(), '.claude', 'projects', dirName); - console.log(`[Pixel Agents] Project dir: ${workspacePath} → ${dirName}`); - return projectDir; -} - -export async function launchNewTerminal( - nextAgentIdRef: { current: number }, - nextTerminalIndexRef: { current: number }, - agents: Map, - activeAgentIdRef: { current: number | null }, - knownJsonlFiles: Set, - fileWatchers: Map, - pollingTimers: Map>, - waitingTimers: Map>, - permissionTimers: Map>, - jsonlPollTimers: Map>, - projectScanTimerRef: { current: ReturnType | null }, - webview: vscode.Webview | undefined, - persistAgents: () => void, - folderPath?: string, -): Promise { - const folders = vscode.workspace.workspaceFolders; - const cwd = folderPath || folders?.[0]?.uri.fsPath; - const isMultiRoot = !!(folders && folders.length > 1); - const idx = nextTerminalIndexRef.current++; - const terminal = vscode.window.createTerminal({ - name: `${TERMINAL_NAME_PREFIX} #${idx}`, - cwd, - }); - terminal.show(); - - const sessionId = crypto.randomUUID(); - terminal.sendText(`claude --session-id ${sessionId}`); - - const projectDir = getProjectDirPath(cwd); - if (!projectDir) { - console.log(`[Pixel Agents] No project dir, cannot track agent`); - return; - } - - // Pre-register expected JSONL file so project scan won't treat it as a /clear file - const expectedFile = path.join(projectDir, `${sessionId}.jsonl`); - knownJsonlFiles.add(expectedFile); - - // Create agent immediately (before JSONL file exists) - const id = nextAgentIdRef.current++; - const folderName = isMultiRoot && cwd ? path.basename(cwd) : undefined; - const agent: AgentState = { - id, - terminalRef: terminal, - projectDir, - jsonlFile: expectedFile, - fileOffset: 0, - lineBuffer: '', - activeToolIds: new Set(), - activeToolStatuses: new Map(), - activeToolNames: new Map(), - activeSubagentToolIds: new Map(), - activeSubagentToolNames: new Map(), - isWaiting: false, - permissionSent: false, - hadToolsInTurn: false, - folderName, - }; - - agents.set(id, agent); - activeAgentIdRef.current = id; - persistAgents(); - console.log(`[Pixel Agents] Agent ${id}: created for terminal ${terminal.name}`); - webview?.postMessage({ type: 'agentCreated', id, folderName }); - - ensureProjectScan( - projectDir, - knownJsonlFiles, - projectScanTimerRef, - activeAgentIdRef, - nextAgentIdRef, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - persistAgents, - ); - - // Poll for the specific JSONL file to appear - const pollTimer = setInterval(() => { - try { - if (fs.existsSync(agent.jsonlFile)) { - console.log( - `[Pixel Agents] Agent ${id}: found JSONL file ${path.basename(agent.jsonlFile)}`, - ); - clearInterval(pollTimer); - jsonlPollTimers.delete(id); - startFileWatching( - id, - agent.jsonlFile, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - ); - readNewLines(id, agents, waitingTimers, permissionTimers, webview); - } - } catch { - /* file may not exist yet */ - } - }, JSONL_POLL_INTERVAL_MS); - jsonlPollTimers.set(id, pollTimer); -} - -export function removeAgent( - agentId: number, - agents: Map, - fileWatchers: Map, - pollingTimers: Map>, - waitingTimers: Map>, - permissionTimers: Map>, - jsonlPollTimers: Map>, - persistAgents: () => void, -): void { - const agent = agents.get(agentId); - if (!agent) return; - - // Stop JSONL poll timer - const jpTimer = jsonlPollTimers.get(agentId); - if (jpTimer) { - clearInterval(jpTimer); - } - jsonlPollTimers.delete(agentId); - - // Stop file watching - fileWatchers.get(agentId)?.close(); - fileWatchers.delete(agentId); - const pt = pollingTimers.get(agentId); - if (pt) { - clearInterval(pt); - } - pollingTimers.delete(agentId); - try { - fs.unwatchFile(agent.jsonlFile); - } catch { - /* ignore */ - } - - // Cancel timers - cancelWaitingTimer(agentId, waitingTimers); - cancelPermissionTimer(agentId, permissionTimers); - - // Remove from maps - agents.delete(agentId); - persistAgents(); -} - -export function persistAgents( - agents: Map, - context: vscode.ExtensionContext, -): void { - const persisted: PersistedAgent[] = []; - for (const agent of agents.values()) { - persisted.push({ - id: agent.id, - terminalName: agent.terminalRef.name, - jsonlFile: agent.jsonlFile, - projectDir: agent.projectDir, - folderName: agent.folderName, - }); - } - context.workspaceState.update(WORKSPACE_KEY_AGENTS, persisted); -} - -export function restoreAgents( - context: vscode.ExtensionContext, - nextAgentIdRef: { current: number }, - nextTerminalIndexRef: { current: number }, - agents: Map, - knownJsonlFiles: Set, - fileWatchers: Map, - pollingTimers: Map>, - waitingTimers: Map>, - permissionTimers: Map>, - jsonlPollTimers: Map>, - projectScanTimerRef: { current: ReturnType | null }, - activeAgentIdRef: { current: number | null }, - webview: vscode.Webview | undefined, - doPersist: () => void, -): void { - const persisted = context.workspaceState.get(WORKSPACE_KEY_AGENTS, []); - if (persisted.length === 0) return; - - const liveTerminals = vscode.window.terminals; - let maxId = 0; - let maxIdx = 0; - let restoredProjectDir: string | null = null; - - for (const p of persisted) { - const terminal = liveTerminals.find((t) => t.name === p.terminalName); - if (!terminal) continue; - - const agent: AgentState = { - id: p.id, - terminalRef: terminal, - projectDir: p.projectDir, - jsonlFile: p.jsonlFile, - fileOffset: 0, - lineBuffer: '', - activeToolIds: new Set(), - activeToolStatuses: new Map(), - activeToolNames: new Map(), - activeSubagentToolIds: new Map(), - activeSubagentToolNames: new Map(), - isWaiting: false, - permissionSent: false, - hadToolsInTurn: false, - folderName: p.folderName, - }; - - agents.set(p.id, agent); - knownJsonlFiles.add(p.jsonlFile); - console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); - - if (p.id > maxId) maxId = p.id; - // Extract terminal index from name like "Claude Code #3" - const match = p.terminalName.match(/#(\d+)$/); - if (match) { - const idx = parseInt(match[1], 10); - if (idx > maxIdx) maxIdx = idx; - } - - restoredProjectDir = p.projectDir; - - // Start file watching if JSONL exists, skipping to end of file - try { - if (fs.existsSync(p.jsonlFile)) { - const stat = fs.statSync(p.jsonlFile); - agent.fileOffset = stat.size; - startFileWatching( - p.id, - p.jsonlFile, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - ); - } else { - // Poll for the file to appear - const pollTimer = setInterval(() => { - try { - if (fs.existsSync(agent.jsonlFile)) { - console.log(`[Pixel Agents] Restored agent ${p.id}: found JSONL file`); - clearInterval(pollTimer); - jsonlPollTimers.delete(p.id); - const stat = fs.statSync(agent.jsonlFile); - agent.fileOffset = stat.size; - startFileWatching( - p.id, - agent.jsonlFile, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - ); - } - } catch { - /* file may not exist yet */ - } - }, JSONL_POLL_INTERVAL_MS); - jsonlPollTimers.set(p.id, pollTimer); - } - } catch { - /* ignore errors during restore */ - } - } - - // Advance counters past restored IDs - if (maxId >= nextAgentIdRef.current) { - nextAgentIdRef.current = maxId + 1; - } - if (maxIdx >= nextTerminalIndexRef.current) { - nextTerminalIndexRef.current = maxIdx + 1; - } - - // Re-persist cleaned-up list (removes entries whose terminals are gone) - doPersist(); - - // Start project scan for /clear detection - if (restoredProjectDir) { - ensureProjectScan( - restoredProjectDir, - knownJsonlFiles, - projectScanTimerRef, - activeAgentIdRef, - nextAgentIdRef, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - doPersist, - ); - } -} - -export function sendExistingAgents( - agents: Map, - context: vscode.ExtensionContext, - webview: vscode.Webview | undefined, -): void { - if (!webview) return; - const agentIds: number[] = []; - for (const id of agents.keys()) { - agentIds.push(id); - } - agentIds.sort((a, b) => a - b); - - // Include persisted palette/seatId from separate key - const agentMeta = context.workspaceState.get< - Record - >(WORKSPACE_KEY_AGENT_SEATS, {}); - - // Include folderName per agent - const folderNames: Record = {}; - for (const [id, agent] of agents) { - if (agent.folderName) { - folderNames[id] = agent.folderName; - } - } - console.log( - `[Pixel Agents] sendExistingAgents: agents=${JSON.stringify(agentIds)}, meta=${JSON.stringify(agentMeta)}`, - ); - - webview.postMessage({ - type: 'existingAgents', - agents: agentIds, - agentMeta, - folderNames, - }); - - sendCurrentAgentStatuses(agents, webview); -} - -export function sendCurrentAgentStatuses( - agents: Map, - webview: vscode.Webview | undefined, -): void { - if (!webview) return; - for (const [agentId, agent] of agents) { - // Re-send active tools - for (const [toolId, status] of agent.activeToolStatuses) { - webview.postMessage({ - type: 'agentToolStart', - id: agentId, - toolId, - status, - }); - } - // Re-send waiting status - if (agent.isWaiting) { - webview.postMessage({ - type: 'agentStatus', - id: agentId, - status: 'waiting', - }); - } - } -} - -export function sendLayout( - context: vscode.ExtensionContext, - webview: vscode.Webview | undefined, - defaultLayout?: Record | null, -): void { - if (!webview) return; - const result = migrateAndLoadLayout(context, defaultLayout); - webview.postMessage({ - type: 'layoutLoaded', - layout: result?.layout ?? null, - wasReset: result?.wasReset ?? false, - }); -} diff --git a/src/assetLoader.ts b/src/assetLoader.ts index 0e5f93fa..a6525bf1 100644 --- a/src/assetLoader.ts +++ b/src/assetLoader.ts @@ -7,7 +7,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as vscode from 'vscode'; import { CHAR_COUNT, CHAR_FRAMES_PER_ROW, WALL_BITMASK_COUNT } from '../shared/assets/constants.js'; import type { @@ -27,6 +26,7 @@ import type { CharacterDirectionSprites } from '../shared/assets/types.js'; export type { CharacterDirectionSprites } from '../shared/assets/types.js'; import { LAYOUT_REVISION_KEY } from './constants.js'; +import type { PostMessage } from './plugin/types.js'; export type { FurnitureAsset }; @@ -281,8 +281,8 @@ export async function loadWallTiles(assetsRoot: string): Promise(); + private nextAgentId = { current: 1 }; + private activeAgentId = { current: null as number | null }; + private knownJsonlFiles = new Set(); + private projectScanTimer = { current: null as ReturnType | null }; + private fileWatchers = new Map(); + private pollingTimers = new Map>(); + private waitingTimers = new Map>(); + private permissionTimers = new Map>(); + private jsonlPollTimers = new Map>(); + private layoutWatcher: LayoutWatcher | null = null; + private defaultLayout: Record | null = null; + + constructor(private readonly plugin: IPixelAgentsPlugin) {} + + start(): void { + const { agentProvider, messageBridge, runtimeUI } = this.plugin; + + messageBridge.onReady(() => void this.handleWebviewReady()); + + messageBridge.onMessage((message) => { + if (message.type !== 'webviewReady') { + void this.handleWebviewMessage(message); + } + }); + + agentProvider.onAgentClosed((id) => { + this.handleAgentClosed(id); + }); + + if (agentProvider.onAgentFocused) { + agentProvider.onAgentFocused((id) => { + this.activeAgentId.current = id; + if (id !== null) { + messageBridge.postMessage({ type: 'agentSelected', id }); + } + }); + } + + runtimeUI.onWorkspaceFoldersChanged((folders) => { + if (folders.length > 1) { + messageBridge.postMessage({ type: 'workspaceFolders', folders }); + } + }); + } + + private get postMessage(): PostMessage { + return (message) => this.plugin.messageBridge.postMessage(message); + } + + private persistAgents(): void { + const persisted: PersistedAgentHandle[] = []; + for (const agent of this.agents.values()) { + persisted.push(agent.handle.serialize()); + } + void this.plugin.runtimeUI.setState(WORKSPACE_KEY_AGENTS, persisted); + } + + private createAgentState( + handle: IAgentHandle, + projectDir: string, + jsonlFile: string, + folderName?: string, + ): AgentState { + return { + id: handle.id, + handle, + projectDir, + jsonlFile, + fileOffset: 0, + lineBuffer: '', + activeToolIds: new Set(), + activeToolStatuses: new Map(), + activeToolNames: new Map(), + activeSubagentToolIds: new Map(), + activeSubagentToolNames: new Map(), + isWaiting: false, + permissionSent: false, + hadToolsInTurn: false, + folderName, + }; + } + + private startJsonlPolling(agent: AgentState): void { + const id = agent.id; + const pollTimer = setInterval(() => { + try { + if (fs.existsSync(agent.jsonlFile)) { + console.log( + `[Pixel Agents] Agent ${id}: found JSONL file ${path.basename(agent.jsonlFile)}`, + ); + clearInterval(pollTimer); + this.jsonlPollTimers.delete(id); + startFileWatching( + id, + agent.jsonlFile, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + ); + readNewLines( + id, + this.agents, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + ); + } + } catch { + /* file may not exist yet */ + } + }, JSONL_POLL_INTERVAL_MS); + this.jsonlPollTimers.set(id, pollTimer); + } + + private removeAgent(agentId: number): void { + const agent = this.agents.get(agentId); + if (!agent) return; + + const jpTimer = this.jsonlPollTimers.get(agentId); + if (jpTimer) clearInterval(jpTimer); + this.jsonlPollTimers.delete(agentId); + + this.fileWatchers.get(agentId)?.close(); + this.fileWatchers.delete(agentId); + const pt = this.pollingTimers.get(agentId); + if (pt) clearInterval(pt); + this.pollingTimers.delete(agentId); + try { + fs.unwatchFile(agent.jsonlFile); + } catch { + /* ignore */ + } + + cancelWaitingTimer(agentId, this.waitingTimers); + cancelPermissionTimer(agentId, this.permissionTimers); + this.agents.delete(agentId); + this.persistAgents(); + } + + private handleAgentClosed(agentId: number): void { + if (this.activeAgentId.current === agentId) { + this.activeAgentId.current = null; + } + this.removeAgent(agentId); + this.plugin.messageBridge.postMessage({ type: 'agentClosed', id: agentId }); + } + + private async handleWebviewReady(): Promise { + const { agentProvider, messageBridge, runtimeUI } = this.plugin; + + // Restore agents from persisted state + const persisted = runtimeUI.getState(WORKSPACE_KEY_AGENTS) ?? []; + if (persisted.length > 0) { + const handles = await agentProvider.restoreAgents(persisted); + let maxId = 0; + + for (const handle of handles) { + const agent = this.createAgentState( + handle, + getProjectDir(handle.workspacePath), + path.join(getProjectDir(handle.workspacePath), `${handle.sessionId}.jsonl`), + undefined, + ); + + // Find original persisted entry for folderName + const p = persisted.find((x) => x.id === handle.id); + if (p?.folderName) { + agent.folderName = p.folderName as string; + } + + this.agents.set(handle.id, agent); + this.knownJsonlFiles.add(agent.jsonlFile); + console.log(`[Pixel Agents] Restored agent ${handle.id} → "${handle.displayName}"`); + + if (handle.id > maxId) maxId = handle.id; + + try { + if (fs.existsSync(agent.jsonlFile)) { + const stat = fs.statSync(agent.jsonlFile); + agent.fileOffset = stat.size; + startFileWatching( + handle.id, + agent.jsonlFile, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + ); + } else { + this.startJsonlPolling(agent); + } + } catch { + /* ignore */ + } + } + + if (maxId >= this.nextAgentId.current) { + this.nextAgentId.current = maxId + 1; + } + + // Re-persist cleaned-up list (entries whose processes are gone are dropped) + this.persistAgents(); + } + + // Send settings + const soundEnabled = runtimeUI.getGlobalState(GLOBAL_KEY_SOUND_ENABLED) ?? true; + messageBridge.postMessage({ type: 'settingsLoaded', soundEnabled }); + + // Send workspace folders (multi-root only) + const folders = runtimeUI.getWorkspaceFolders(); + if (folders.length > 1) { + messageBridge.postMessage({ type: 'workspaceFolders', folders }); + } + + // Determine assets root + const workspacePath = folders[0]?.path; + const projectDir = workspacePath ? getProjectDir(workspacePath) : null; + console.log('[Extension] workspacePath:', workspacePath); + console.log('[Extension] projectDir:', projectDir); + + if (projectDir) { + ensureProjectScan( + projectDir, + this.knownJsonlFiles, + this.projectScanTimer, + this.activeAgentId, + this.nextAgentId, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + this.persistAgents.bind(this), + (file, pd) => this.onNewUnownedFile(file, pd), + ); + } + + // Load and send assets + await this.loadAndSendAssets(workspacePath); + + // Send existing agents + this.sendExistingAgents(); + } + + private onNewUnownedFile(file: string, projectDir: string): void { + const { agentProvider, messageBridge } = this.plugin; + if (this.activeAgentId.current !== null) { + // Active agent → reassign + console.log( + `[Pixel Agents] New JSONL detected: ${path.basename(file)}, reassigning to agent ${this.activeAgentId.current}`, + ); + reassignAgentToFile( + this.activeAgentId.current, + file, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + this.persistAgents.bind(this), + ); + return; + } + + if (!agentProvider.adoptForFile) return; + const id = this.nextAgentId.current++; + const handle = agentProvider.adoptForFile(file, projectDir, id); + if (!handle) { + this.nextAgentId.current--; // give back the ID + return; + } + + const agent = this.createAgentState(handle, projectDir, file); + this.agents.set(id, agent); + this.activeAgentId.current = id; + this.persistAgents(); + + messageBridge.postMessage({ type: 'agentCreated', id }); + startFileWatching( + id, + file, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + ); + readNewLines(id, this.agents, this.waitingTimers, this.permissionTimers, this.postMessage); + } + + private async loadAndSendAssets(workspacePath: string | undefined): Promise { + let assetsRoot: string | undefined = this.plugin.getAssetsRoot?.(); + if (!assetsRoot && workspacePath) { + assetsRoot = workspacePath; + } + + if (!assetsRoot) { + console.log('[Extension] ⚠️ No assets directory found'); + this.sendLayout(); + this.startLayoutWatcher(); + return; + } + + console.log('[Extension] Using assetsRoot:', assetsRoot); + + try { + this.defaultLayout = loadDefaultLayout(assetsRoot); + + const charSprites = await loadCharacterSprites(assetsRoot); + if (charSprites) sendCharacterSpritesToWebview(this.postMessage, charSprites); + + const floorTiles = await loadFloorTiles(assetsRoot); + if (floorTiles) sendFloorTilesToWebview(this.postMessage, floorTiles); + + const wallTiles = await loadWallTiles(assetsRoot); + if (wallTiles) sendWallTilesToWebview(this.postMessage, wallTiles); + + const assets = await loadFurnitureAssets(assetsRoot); + if (assets) { + console.log('[Extension] ✅ Assets loaded, sending to webview'); + sendAssetsToWebview(this.postMessage, assets); + } + } catch (err) { + console.error('[Extension] ❌ Error loading assets:', err); + } + + // Always send layout after assets (even if asset loading failed) + this.sendLayout(); + this.startLayoutWatcher(); + } + + private sendLayout(): void { + const result = migrateAndLoadLayout(this.plugin.runtimeUI, this.defaultLayout); + this.plugin.messageBridge.postMessage({ + type: 'layoutLoaded', + layout: result?.layout ?? null, + wasReset: result?.wasReset ?? false, + }); + } + + private startLayoutWatcher(): void { + if (this.layoutWatcher) return; + this.layoutWatcher = watchLayoutFile((layout) => { + console.log('[Pixel Agents] External layout change — pushing to webview'); + this.plugin.messageBridge.postMessage({ type: 'layoutLoaded', layout }); + }); + } + + private sendExistingAgents(): void { + const { messageBridge, runtimeUI } = this.plugin; + const agentIds: number[] = []; + for (const id of this.agents.keys()) agentIds.push(id); + agentIds.sort((a, b) => a - b); + + const agentMeta = + runtimeUI.getState>( + WORKSPACE_KEY_AGENT_SEATS, + ) ?? {}; + + const folderNames: Record = {}; + for (const [id, agent] of this.agents) { + if (agent.folderName) folderNames[id] = agent.folderName; + } + + messageBridge.postMessage({ type: 'existingAgents', agents: agentIds, agentMeta, folderNames }); + + // Re-send active statuses + for (const [agentId, agent] of this.agents) { + for (const [toolId, status] of agent.activeToolStatuses) { + messageBridge.postMessage({ type: 'agentToolStart', id: agentId, toolId, status }); + } + if (agent.isWaiting) { + messageBridge.postMessage({ type: 'agentStatus', id: agentId, status: 'waiting' }); + } + } + } + + private async handleWebviewMessage(message: Record): Promise { + const { agentProvider, messageBridge, runtimeUI } = this.plugin; + + if (message.type === 'openClaude') { + const folderPath = message.folderPath as string | undefined; + const folders = runtimeUI.getWorkspaceFolders(); + const workspacePath = folderPath ?? folders[0]?.path; + if (!workspacePath) { + console.log('[Pixel Agents] No workspace path, cannot spawn agent'); + return; + } + const isMultiRoot = folders.length > 1; + const id = this.nextAgentId.current++; + const sessionId = crypto.randomUUID(); + const projectDir = getProjectDir(workspacePath); + const expectedFile = path.join(projectDir, `${sessionId}.jsonl`); + this.knownJsonlFiles.add(expectedFile); + + const handle = await agentProvider.spawnAgent({ id, sessionId, workspacePath }); + const folderName = isMultiRoot ? path.basename(workspacePath) : undefined; + const agent = this.createAgentState(handle, projectDir, expectedFile, folderName); + + this.agents.set(id, agent); + this.activeAgentId.current = id; + this.persistAgents(); + console.log(`[Pixel Agents] Agent ${id}: created`); + messageBridge.postMessage({ type: 'agentCreated', id, folderName }); + + ensureProjectScan( + projectDir, + this.knownJsonlFiles, + this.projectScanTimer, + this.activeAgentId, + this.nextAgentId, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.postMessage, + this.persistAgents.bind(this), + (file, pd) => this.onNewUnownedFile(file, pd), + ); + + this.startJsonlPolling(agent); + } else if (message.type === 'focusAgent') { + const agent = this.agents.get(message.id as number); + agent?.handle.focus(); + } else if (message.type === 'closeAgent') { + const agent = this.agents.get(message.id as number); + agent?.handle.close(); + } else if (message.type === 'saveAgentSeats') { + console.log('[Pixel Agents] saveAgentSeats:', JSON.stringify(message.seats)); + await runtimeUI.setState(WORKSPACE_KEY_AGENT_SEATS, message.seats); + } else if (message.type === 'saveLayout') { + this.layoutWatcher?.markOwnWrite(); + writeLayoutToFile(message.layout as Record); + } else if (message.type === 'setSoundEnabled') { + await runtimeUI.setGlobalState(GLOBAL_KEY_SOUND_ENABLED, message.enabled); + } else if (message.type === 'openSessionsFolder') { + const folders = runtimeUI.getWorkspaceFolders(); + const workspacePath = folders[0]?.path; + if (workspacePath) { + const projectDir = getProjectDir(workspacePath); + if (fs.existsSync(projectDir)) { + await runtimeUI.openPath(projectDir); + } + } + } else if (message.type === 'exportLayout') { + const layout = readLayoutFromFile(); + if (!layout) { + await runtimeUI.showInformationMessage('Pixel Agents: No saved layout to export.'); + return; + } + const savePath = await runtimeUI.showSaveDialog({ + filters: { 'JSON Files': ['json'] }, + defaultPath: path.join(os.homedir(), 'pixel-agents-layout.json'), + }); + if (savePath) { + fs.writeFileSync(savePath, JSON.stringify(layout, null, 2), 'utf-8'); + await runtimeUI.showInformationMessage('Pixel Agents: Layout exported successfully.'); + } + } else if (message.type === 'importLayout') { + const paths = await runtimeUI.showOpenDialog({ + filters: { 'JSON Files': ['json'] }, + canSelectMany: false, + }); + if (!paths || paths.length === 0) return; + try { + const raw = fs.readFileSync(paths[0], 'utf-8'); + const imported = JSON.parse(raw) as Record; + if (imported.version !== 1 || !Array.isArray(imported.tiles)) { + await runtimeUI.showErrorMessage('Pixel Agents: Invalid layout file.'); + return; + } + this.layoutWatcher?.markOwnWrite(); + writeLayoutToFile(imported); + messageBridge.postMessage({ type: 'layoutLoaded', layout: imported }); + await runtimeUI.showInformationMessage('Pixel Agents: Layout imported successfully.'); + } catch { + await runtimeUI.showErrorMessage('Pixel Agents: Failed to read or parse layout file.'); + } + } + } + + dispose(): void { + this.layoutWatcher?.dispose(); + this.layoutWatcher = null; + for (const id of [...this.agents.keys()]) { + this.removeAgent(id); + } + if (this.projectScanTimer.current) { + clearInterval(this.projectScanTimer.current); + this.projectScanTimer.current = null; + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 55ee3977..2d30c9b1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,29 +1,35 @@ import * as vscode from 'vscode'; import { COMMAND_EXPORT_DEFAULT_LAYOUT, COMMAND_SHOW_PANEL, VIEW_ID } from './constants.js'; -import { PixelAgentsViewProvider } from './PixelAgentsViewProvider.js'; +import { AgentLifecycle } from './core/agentLifecycle.js'; +import { registerPlugin } from './plugin/registry.js'; +import { VSCodePlugin } from './vscode/VSCodePlugin.js'; -let providerInstance: PixelAgentsViewProvider | undefined; +let lifecycle: AgentLifecycle | undefined; export function activate(context: vscode.ExtensionContext) { - const provider = new PixelAgentsViewProvider(context); - providerInstance = provider; + const plugin = new VSCodePlugin(context); + registerPlugin(plugin); - context.subscriptions.push(vscode.window.registerWebviewViewProvider(VIEW_ID, provider)); + lifecycle = new AgentLifecycle(plugin); + lifecycle.start(); + + context.subscriptions.push(vscode.window.registerWebviewViewProvider(VIEW_ID, plugin)); context.subscriptions.push( vscode.commands.registerCommand(COMMAND_SHOW_PANEL, () => { - vscode.commands.executeCommand(`${VIEW_ID}.focus`); + void vscode.commands.executeCommand(`${VIEW_ID}.focus`); }), ); context.subscriptions.push( vscode.commands.registerCommand(COMMAND_EXPORT_DEFAULT_LAYOUT, () => { - provider.exportDefaultLayout(); + plugin.exportDefaultLayout(); }), ); } export function deactivate() { - providerInstance?.dispose(); + lifecycle?.dispose(); + lifecycle = undefined; } diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index f2a60dfd..25db6944 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -1,8 +1,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as vscode from 'vscode'; import { FILE_WATCHER_POLL_INTERVAL_MS, PROJECT_SCAN_INTERVAL_MS } from './constants.js'; +import type { PostMessage } from './plugin/types.js'; import { cancelPermissionTimer, cancelWaitingTimer, clearAgentActivity } from './timerManager.js'; import { processTranscriptLine } from './transcriptParser.js'; import type { AgentState } from './types.js'; @@ -15,12 +15,12 @@ export function startFileWatching( pollingTimers: Map>, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { // Primary: fs.watch (unreliable on macOS — may miss events) try { const watcher = fs.watch(filePath, () => { - readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); + readNewLines(agentId, agents, waitingTimers, permissionTimers, postMessage); }); fileWatchers.set(agentId, watcher); } catch (e) { @@ -30,7 +30,7 @@ export function startFileWatching( // Secondary: fs.watchFile (stat-based polling, reliable on macOS) try { fs.watchFile(filePath, { interval: FILE_WATCHER_POLL_INTERVAL_MS }, () => { - readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); + readNewLines(agentId, agents, waitingTimers, permissionTimers, postMessage); }); } catch (e) { console.log(`[Pixel Agents] fs.watchFile failed for agent ${agentId}: ${e}`); @@ -47,7 +47,7 @@ export function startFileWatching( } return; } - readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); + readNewLines(agentId, agents, waitingTimers, permissionTimers, postMessage); }, FILE_WATCHER_POLL_INTERVAL_MS); pollingTimers.set(agentId, interval); } @@ -57,7 +57,7 @@ export function readNewLines( agents: Map, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { const agent = agents.get(agentId); if (!agent) return; @@ -82,13 +82,13 @@ export function readNewLines( cancelPermissionTimer(agentId, permissionTimers); if (agent.permissionSent) { agent.permissionSent = false; - webview?.postMessage({ type: 'agentToolPermissionClear', id: agentId }); + postMessage?.({ type: 'agentToolPermissionClear', id: agentId }); } } for (const line of lines) { if (!line.trim()) continue; - processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); + processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, postMessage); } } catch (e) { console.log(`[Pixel Agents] Read error for agent ${agentId}: ${e}`); @@ -106,8 +106,9 @@ export function ensureProjectScan( pollingTimers: Map>, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, persistAgents: () => void, + onNewUnownedFile: (file: string, projectDir: string) => void, ): void { if (projectScanTimerRef.current) return; // Seed with all existing JSONL files so we only react to truly new ones @@ -134,8 +135,9 @@ export function ensureProjectScan( pollingTimers, waitingTimers, permissionTimers, - webview, + postMessage, persistAgents, + onNewUnownedFile, ); }, PROJECT_SCAN_INTERVAL_MS); } @@ -144,14 +146,15 @@ function scanForNewJsonlFiles( projectDir: string, knownJsonlFiles: Set, activeAgentIdRef: { current: number | null }, - nextAgentIdRef: { current: number }, + _nextAgentIdRef: { current: number }, agents: Map, fileWatchers: Map, pollingTimers: Map>, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, persistAgents: () => void, + onNewUnownedFile: (file: string, projectDir: string) => void, ): void { let files: string[]; try { @@ -179,96 +182,17 @@ function scanForNewJsonlFiles( pollingTimers, waitingTimers, permissionTimers, - webview, + postMessage, persistAgents, ); } else { - // No active agent → try to adopt the focused terminal - const activeTerminal = vscode.window.activeTerminal; - if (activeTerminal) { - let owned = false; - for (const agent of agents.values()) { - if (agent.terminalRef === activeTerminal) { - owned = true; - break; - } - } - if (!owned) { - adoptTerminalForFile( - activeTerminal, - file, - projectDir, - nextAgentIdRef, - agents, - activeAgentIdRef, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - persistAgents, - ); - } - } + // No active agent → delegate to caller (plugin-agnostic adoption) + onNewUnownedFile(file, projectDir); } } } } -function adoptTerminalForFile( - terminal: vscode.Terminal, - jsonlFile: string, - projectDir: string, - nextAgentIdRef: { current: number }, - agents: Map, - activeAgentIdRef: { current: number | null }, - fileWatchers: Map, - pollingTimers: Map>, - waitingTimers: Map>, - permissionTimers: Map>, - webview: vscode.Webview | undefined, - persistAgents: () => void, -): void { - const id = nextAgentIdRef.current++; - const agent: AgentState = { - id, - terminalRef: terminal, - projectDir, - jsonlFile, - fileOffset: 0, - lineBuffer: '', - activeToolIds: new Set(), - activeToolStatuses: new Map(), - activeToolNames: new Map(), - activeSubagentToolIds: new Map(), - activeSubagentToolNames: new Map(), - isWaiting: false, - permissionSent: false, - hadToolsInTurn: false, - }; - - agents.set(id, agent); - activeAgentIdRef.current = id; - persistAgents(); - - console.log( - `[Pixel Agents] Agent ${id}: adopted terminal "${terminal.name}" for ${path.basename(jsonlFile)}`, - ); - webview?.postMessage({ type: 'agentCreated', id }); - - startFileWatching( - id, - jsonlFile, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - ); - readNewLines(id, agents, waitingTimers, permissionTimers, webview); -} - export function reassignAgentToFile( agentId: number, newFilePath: string, @@ -277,7 +201,7 @@ export function reassignAgentToFile( pollingTimers: Map>, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, persistAgents: () => void, ): void { const agent = agents.get(agentId); @@ -300,7 +224,7 @@ export function reassignAgentToFile( // Clear activity cancelWaitingTimer(agentId, waitingTimers); cancelPermissionTimer(agentId, permissionTimers); - clearAgentActivity(agent, agentId, permissionTimers, webview); + clearAgentActivity(agent, agentId, permissionTimers, postMessage); // Swap to new file agent.jsonlFile = newFilePath; @@ -317,7 +241,7 @@ export function reassignAgentToFile( pollingTimers, waitingTimers, permissionTimers, - webview, + postMessage, ); - readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); + readNewLines(agentId, agents, waitingTimers, permissionTimers, postMessage); } diff --git a/src/layoutPersistence.ts b/src/layoutPersistence.ts index b0661fcc..4d9002af 100644 --- a/src/layoutPersistence.ts +++ b/src/layoutPersistence.ts @@ -1,7 +1,6 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import type { ExtensionContext } from 'vscode'; import { LAYOUT_FILE_DIR, @@ -10,6 +9,7 @@ import { LAYOUT_REVISION_KEY, WORKSPACE_KEY_LAYOUT, } from './constants.js'; +import type { IRuntimeUI } from './plugin/types.js'; export interface LayoutWatcher { markOwnWrite(): void; @@ -62,7 +62,7 @@ export interface LayoutLoadResult { * 4. Else → return null */ export function migrateAndLoadLayout( - context: ExtensionContext, + runtimeUI: IRuntimeUI, defaultLayout?: Record | null, ): LayoutLoadResult | null { // 1. Try file — but reset if bundled default has a newer revision @@ -82,11 +82,11 @@ export function migrateAndLoadLayout( } // 2. Migrate from workspace state - const fromState = context.workspaceState.get>(WORKSPACE_KEY_LAYOUT); + const fromState = runtimeUI.getState>(WORKSPACE_KEY_LAYOUT); if (fromState) { console.log('[Pixel Agents] Migrating layout from workspace state to file'); writeLayoutToFile(fromState); - context.workspaceState.update(WORKSPACE_KEY_LAYOUT, undefined); + void runtimeUI.setState(WORKSPACE_KEY_LAYOUT, undefined); return { layout: fromState, wasReset: false }; } diff --git a/src/plugin/registry.ts b/src/plugin/registry.ts new file mode 100644 index 00000000..9480446a --- /dev/null +++ b/src/plugin/registry.ts @@ -0,0 +1,12 @@ +import type { IPixelAgentsPlugin } from './types.js'; + +let activePlugin: IPixelAgentsPlugin | undefined; + +export function registerPlugin(plugin: IPixelAgentsPlugin): void { + activePlugin = plugin; +} + +export function getPlugin(): IPixelAgentsPlugin { + if (!activePlugin) throw new Error('[Pixel Agents] No plugin registered'); + return activePlugin; +} diff --git a/src/plugin/types.ts b/src/plugin/types.ts new file mode 100644 index 00000000..00c3f439 --- /dev/null +++ b/src/plugin/types.ts @@ -0,0 +1,89 @@ +export type PostMessage = (message: Record) => void; + +export interface IDisposable { + dispose(): void; +} + +export type Event = (handler: (value: T) => void) => IDisposable; + +export interface WorkspaceFolder { + name: string; + path: string; +} + +export interface OpenDialogOptions { + filters?: Record; + canSelectMany?: boolean; +} + +export interface SaveDialogOptions { + filters?: Record; + defaultPath?: string; +} + +export interface IRuntimeUI { + showOpenDialog(options?: OpenDialogOptions): Promise; + showSaveDialog(options?: SaveDialogOptions): Promise; + showInformationMessage(message: string): Promise; + showErrorMessage(message: string): Promise; + openPath(fsPath: string): Promise; + getWorkspaceFolders(): WorkspaceFolder[]; + onWorkspaceFoldersChanged: Event; + getState(key: string): T | undefined; + setState(key: string, value: T): Promise; + getGlobalState(key: string): T | undefined; + setGlobalState(key: string, value: T): Promise; +} + +export interface SpawnAgentOptions { + id: number; + sessionId: string; + workspacePath: string; +} + +export interface IAgentHandle { + readonly id: number; + readonly sessionId: string; + readonly workspacePath: string; + readonly displayName: string; + focus(): void; + close(): void; + serialize(): PersistedAgentHandle; +} + +export interface PersistedAgentHandle { + id: number; + sessionId: string; + workspacePath: string; + displayName: string; + [key: string]: unknown; +} + +export interface IAgentProvider { + spawnAgent(options: SpawnAgentOptions): Promise; + restoreAgents(persisted: PersistedAgentHandle[]): Promise; + /** Optional: adopt an active/focused process for a newly detected JSONL file */ + adoptForFile?(file: string, projectDir: string, id: number): IAgentHandle | null; + /** Optional: fires when user focuses a managed agent (e.g. clicks its terminal) */ + onAgentFocused?: Event; + onAgentClosed: Event; + dispose(): void; +} + +export interface IMessageBridge { + postMessage(message: Record): void; + onMessage(handler: (message: Record) => void): IDisposable; + /** Fires when the webview sends the 'webviewReady' message */ + onReady(handler: () => void): IDisposable; + dispose(): void; +} + +export interface IPixelAgentsPlugin { + readonly name: string; + readonly version: string; + agentProvider: IAgentProvider; + messageBridge: IMessageBridge; + runtimeUI: IRuntimeUI; + /** Optional: absolute path to bundled extension assets */ + getAssetsRoot?(): string | undefined; +} diff --git a/src/timerManager.ts b/src/timerManager.ts index 9464a315..ebf99634 100644 --- a/src/timerManager.ts +++ b/src/timerManager.ts @@ -1,13 +1,12 @@ -import type * as vscode from 'vscode'; - import { PERMISSION_TIMER_DELAY_MS } from './constants.js'; +import type { PostMessage } from './plugin/types.js'; import type { AgentState } from './types.js'; export function clearAgentActivity( agent: AgentState | undefined, agentId: number, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { if (!agent) return; agent.activeToolIds.clear(); @@ -18,8 +17,8 @@ export function clearAgentActivity( agent.isWaiting = false; agent.permissionSent = false; cancelPermissionTimer(agentId, permissionTimers); - webview?.postMessage({ type: 'agentToolsClear', id: agentId }); - webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + postMessage?.({ type: 'agentToolsClear', id: agentId }); + postMessage?.({ type: 'agentStatus', id: agentId, status: 'active' }); } export function cancelWaitingTimer( @@ -38,7 +37,7 @@ export function startWaitingTimer( delayMs: number, agents: Map, waitingTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { cancelWaitingTimer(agentId, waitingTimers); const timer = setTimeout(() => { @@ -47,7 +46,7 @@ export function startWaitingTimer( if (agent) { agent.isWaiting = true; } - webview?.postMessage({ + postMessage?.({ type: 'agentStatus', id: agentId, status: 'waiting', @@ -72,7 +71,7 @@ export function startPermissionTimer( agents: Map, permissionTimers: Map>, permissionExemptTools: Set, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { cancelPermissionTimer(agentId, permissionTimers); const timer = setTimeout(() => { @@ -105,13 +104,13 @@ export function startPermissionTimer( if (hasNonExempt) { agent.permissionSent = true; console.log(`[Pixel Agents] Agent ${agentId}: possible permission wait detected`); - webview?.postMessage({ + postMessage?.({ type: 'agentToolPermission', id: agentId, }); // Also notify stuck sub-agents for (const parentToolId of stuckSubagentParentToolIds) { - webview?.postMessage({ + postMessage?.({ type: 'subagentToolPermission', id: agentId, parentToolId, diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index f462967a..9b1ebccd 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -1,5 +1,4 @@ import * as path from 'path'; -import type * as vscode from 'vscode'; import { BASH_COMMAND_DISPLAY_MAX_LENGTH, @@ -7,6 +6,7 @@ import { TEXT_IDLE_DELAY_MS, TOOL_DONE_DELAY_MS, } from './constants.js'; +import type { PostMessage } from './plugin/types.js'; import { cancelPermissionTimer, cancelWaitingTimer, @@ -63,7 +63,7 @@ export function processTranscriptLine( agents: Map, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { const agent = agents.get(agentId); if (!agent) return; @@ -83,7 +83,7 @@ export function processTranscriptLine( cancelWaitingTimer(agentId, waitingTimers); agent.isWaiting = false; agent.hadToolsInTurn = true; - webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + postMessage?.({ type: 'agentStatus', id: agentId, status: 'active' }); let hasNonExemptTool = false; for (const block of blocks) { if (block.type === 'tool_use' && block.id) { @@ -96,7 +96,7 @@ export function processTranscriptLine( if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) { hasNonExemptTool = true; } - webview?.postMessage({ + postMessage?.({ type: 'agentToolStart', id: agentId, toolId: block.id, @@ -105,17 +105,23 @@ export function processTranscriptLine( } } if (hasNonExemptTool) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); + startPermissionTimer( + agentId, + agents, + permissionTimers, + PERMISSION_EXEMPT_TOOLS, + postMessage, + ); } } else if (blocks.some((b) => b.type === 'text') && !agent.hadToolsInTurn) { // Text-only response in a turn that hasn't used any tools. // turn_duration handles tool-using turns reliably but is never // emitted for text-only turns, so we use a silence-based timer: // if no new JSONL data arrives within TEXT_IDLE_DELAY_MS, mark as waiting. - startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, postMessage); } } else if (record.type === 'progress') { - processProgressRecord(agentId, record, agents, waitingTimers, permissionTimers, webview); + processProgressRecord(agentId, record, agents, waitingTimers, permissionTimers, postMessage); } else if (record.type === 'user') { const content = record.message?.content; if (Array.isArray(content)) { @@ -131,7 +137,7 @@ export function processTranscriptLine( if (completedToolName === 'Task' || completedToolName === 'Agent') { agent.activeSubagentToolIds.delete(completedToolId); agent.activeSubagentToolNames.delete(completedToolId); - webview?.postMessage({ + postMessage?.({ type: 'subagentClear', id: agentId, parentToolId: completedToolId, @@ -142,7 +148,7 @@ export function processTranscriptLine( agent.activeToolNames.delete(completedToolId); const toolId = completedToolId; setTimeout(() => { - webview?.postMessage({ + postMessage?.({ type: 'agentToolDone', id: agentId, toolId, @@ -158,13 +164,13 @@ export function processTranscriptLine( } else { // New user text prompt — new turn starting cancelWaitingTimer(agentId, waitingTimers); - clearAgentActivity(agent, agentId, permissionTimers, webview); + clearAgentActivity(agent, agentId, permissionTimers, postMessage); agent.hadToolsInTurn = false; } } else if (typeof content === 'string' && content.trim()) { // New user text prompt — new turn starting cancelWaitingTimer(agentId, waitingTimers); - clearAgentActivity(agent, agentId, permissionTimers, webview); + clearAgentActivity(agent, agentId, permissionTimers, postMessage); agent.hadToolsInTurn = false; } } else if (record.type === 'system' && record.subtype === 'turn_duration') { @@ -178,13 +184,13 @@ export function processTranscriptLine( agent.activeToolNames.clear(); agent.activeSubagentToolIds.clear(); agent.activeSubagentToolNames.clear(); - webview?.postMessage({ type: 'agentToolsClear', id: agentId }); + postMessage?.({ type: 'agentToolsClear', id: agentId }); } agent.isWaiting = true; agent.permissionSent = false; agent.hadToolsInTurn = false; - webview?.postMessage({ + postMessage?.({ type: 'agentStatus', id: agentId, status: 'waiting', @@ -201,7 +207,7 @@ function processProgressRecord( agents: Map, waitingTimers: Map>, permissionTimers: Map>, - webview: vscode.Webview | undefined, + postMessage: PostMessage | undefined, ): void { const agent = agents.get(agentId); if (!agent) return; @@ -217,7 +223,7 @@ function processProgressRecord( const dataType = data.type as string | undefined; if (dataType === 'bash_progress' || dataType === 'mcp_progress') { if (agent.activeToolIds.has(parentToolId)) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); + startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, postMessage); } return; } @@ -264,7 +270,7 @@ function processProgressRecord( hasNonExemptSubTool = true; } - webview?.postMessage({ + postMessage?.({ type: 'subagentToolStart', id: agentId, parentToolId, @@ -274,7 +280,7 @@ function processProgressRecord( } } if (hasNonExemptSubTool) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); + startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, postMessage); } } else if (msgType === 'user') { for (const block of content) { @@ -295,7 +301,7 @@ function processProgressRecord( const toolId = block.tool_use_id; setTimeout(() => { - webview?.postMessage({ + postMessage?.({ type: 'subagentToolDone', id: agentId, parentToolId, @@ -317,7 +323,7 @@ function processProgressRecord( if (stillHasNonExempt) break; } if (stillHasNonExempt) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); + startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, postMessage); } } } diff --git a/src/types.ts b/src/types.ts index feeec137..390be3ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ -import type * as vscode from 'vscode'; +import type { IAgentHandle } from './plugin/types.js'; export interface AgentState { id: number; - terminalRef: vscode.Terminal; + handle: IAgentHandle; projectDir: string; jsonlFile: string; fileOffset: number; @@ -18,12 +18,3 @@ export interface AgentState { /** Workspace folder name (only set for multi-root workspaces) */ folderName?: string; } - -export interface PersistedAgent { - id: number; - terminalName: string; - jsonlFile: string; - projectDir: string; - /** Workspace folder name (only set for multi-root workspaces) */ - folderName?: string; -} diff --git a/src/vscode/VSCodeAgentProvider.ts b/src/vscode/VSCodeAgentProvider.ts new file mode 100644 index 00000000..721af0e7 --- /dev/null +++ b/src/vscode/VSCodeAgentProvider.ts @@ -0,0 +1,198 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +import type { + Event, + IAgentHandle, + IAgentProvider, + IDisposable, + PersistedAgentHandle, + SpawnAgentOptions, +} from '../plugin/types.js'; + +const TERMINAL_NAME_PREFIX = 'Claude Code'; + +class VSCodeAgentHandle implements IAgentHandle { + constructor( + public readonly id: number, + public readonly sessionId: string, + public readonly workspacePath: string, + public readonly displayName: string, + private readonly terminal: vscode.Terminal, + private readonly terminalName: string, + ) {} + + focus(): void { + this.terminal.show(); + } + + close(): void { + this.terminal.dispose(); + } + + serialize(): PersistedAgentHandle { + return { + id: this.id, + sessionId: this.sessionId, + workspacePath: this.workspacePath, + displayName: this.displayName, + terminalName: this.terminalName, + }; + } + + matchesTerminal(terminal: vscode.Terminal): boolean { + return this.terminal === terminal; + } +} + +export class VSCodeAgentProvider implements IAgentProvider { + private nextTerminalIndex = 1; + private handles = new Map(); + private closedHandlers: ((id: number) => void)[] = []; + private focusedHandlers: ((id: number | null) => void)[] = []; + private readonly disposables: vscode.Disposable[] = []; + + constructor() { + this.disposables.push( + vscode.window.onDidCloseTerminal((terminal) => { + for (const [, handle] of this.handles) { + if (handle.matchesTerminal(terminal)) { + this.handles.delete(handle.id); + for (const handler of this.closedHandlers) { + handler(handle.id); + } + break; + } + } + }), + ); + + this.disposables.push( + vscode.window.onDidChangeActiveTerminal((terminal) => { + if (!terminal) { + for (const handler of this.focusedHandlers) handler(null); + return; + } + for (const [, handle] of this.handles) { + if (handle.matchesTerminal(terminal)) { + for (const handler of this.focusedHandlers) handler(handle.id); + return; + } + } + for (const handler of this.focusedHandlers) handler(null); + }), + ); + } + + async spawnAgent(options: SpawnAgentOptions): Promise { + const idx = this.nextTerminalIndex++; + const terminalName = `${TERMINAL_NAME_PREFIX} #${idx}`; + const terminal = vscode.window.createTerminal({ + name: terminalName, + cwd: options.workspacePath, + }); + terminal.show(); + terminal.sendText(`claude --session-id ${options.sessionId}`); + + const handle = new VSCodeAgentHandle( + options.id, + options.sessionId, + options.workspacePath, + terminalName, + terminal, + terminalName, + ); + this.handles.set(options.id, handle); + return handle; + } + + async restoreAgents(persisted: PersistedAgentHandle[]): Promise { + const liveTerminals = vscode.window.terminals; + const restored: IAgentHandle[] = []; + let maxIdx = 0; + + for (const p of persisted) { + const terminalName = p.terminalName as string | undefined; + if (!terminalName) continue; + const terminal = liveTerminals.find((t) => t.name === terminalName); + if (!terminal) continue; + + const match = terminalName.match(/#(\d+)$/); + if (match) { + const idx = parseInt(match[1], 10); + if (idx > maxIdx) maxIdx = idx; + } + + const handle = new VSCodeAgentHandle( + p.id, + p.sessionId, + p.workspacePath, + p.displayName, + terminal, + terminalName, + ); + this.handles.set(p.id, handle); + restored.push(handle); + } + + if (maxIdx >= this.nextTerminalIndex) { + this.nextTerminalIndex = maxIdx + 1; + } + + return restored; + } + + adoptForFile(file: string, projectDir: string, id: number): IAgentHandle | null { + const activeTerminal = vscode.window.activeTerminal; + if (!activeTerminal) return null; + + // Don't adopt terminals already owned by this provider + for (const [, handle] of this.handles) { + if (handle.matchesTerminal(activeTerminal)) return null; + } + + const terminalName = activeTerminal.name; + // reverse projectDir derivation is not reliable; best effort + const workspacePath = path.dirname(path.dirname(projectDir)); + const sessionId = path.basename(file, '.jsonl'); + const handle = new VSCodeAgentHandle( + id, + sessionId, + workspacePath, + terminalName, + activeTerminal, + terminalName, + ); + this.handles.set(id, handle); + console.log( + `[Pixel Agents] Agent ${id}: adopted terminal "${terminalName}" for ${path.basename(file)}`, + ); + return handle; + } + + onAgentFocused: Event = (handler): IDisposable => { + this.focusedHandlers.push(handler); + return { + dispose: () => { + this.focusedHandlers = this.focusedHandlers.filter((h) => h !== handler); + }, + }; + }; + + onAgentClosed: Event = (handler): IDisposable => { + this.closedHandlers.push(handler); + return { + dispose: () => { + this.closedHandlers = this.closedHandlers.filter((h) => h !== handler); + }, + }; + }; + + dispose(): void { + this.closedHandlers = []; + this.focusedHandlers = []; + this.handles.clear(); + for (const d of this.disposables) d.dispose(); + this.disposables.length = 0; + } +} diff --git a/src/vscode/VSCodeMessageBridge.ts b/src/vscode/VSCodeMessageBridge.ts new file mode 100644 index 00000000..6d30ce73 --- /dev/null +++ b/src/vscode/VSCodeMessageBridge.ts @@ -0,0 +1,70 @@ +import * as fs from 'fs'; +import * as vscode from 'vscode'; + +import type { IDisposable, IMessageBridge } from '../plugin/types.js'; + +export class VSCodeMessageBridge implements IMessageBridge { + private webview: vscode.Webview | undefined; + private messageHandlers: ((msg: Record) => void)[] = []; + private readyHandlers: (() => void)[] = []; + + init(webviewView: vscode.WebviewView, extensionUri: vscode.Uri): void { + this.webview = webviewView.webview; + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.html = getWebviewContent(webviewView.webview, extensionUri); + + webviewView.webview.onDidReceiveMessage((message: Record) => { + if (message.type === 'webviewReady') { + for (const handler of this.readyHandlers) { + handler(); + } + } + for (const handler of this.messageHandlers) { + handler(message); + } + }); + } + + postMessage(message: Record): void { + this.webview?.postMessage(message); + } + + onMessage(handler: (message: Record) => void): IDisposable { + this.messageHandlers.push(handler); + return { + dispose: () => { + this.messageHandlers = this.messageHandlers.filter((h) => h !== handler); + }, + }; + } + + onReady(handler: () => void): IDisposable { + this.readyHandlers.push(handler); + return { + dispose: () => { + this.readyHandlers = this.readyHandlers.filter((h) => h !== handler); + }, + }; + } + + dispose(): void { + this.messageHandlers = []; + this.readyHandlers = []; + this.webview = undefined; + } +} + +function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri): string { + const distPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview'); + const indexPath = vscode.Uri.joinPath(distPath, 'index.html').fsPath; + + let html = fs.readFileSync(indexPath, 'utf-8'); + + html = html.replace(/(href|src)="\.\/([^"]+)"/g, (_match, attr, filePath) => { + const fileUri = vscode.Uri.joinPath(distPath, filePath as string); + const webviewUri = webview.asWebviewUri(fileUri); + return `${attr}="${webviewUri}"`; + }); + + return html; +} diff --git a/src/vscode/VSCodePlugin.ts b/src/vscode/VSCodePlugin.ts new file mode 100644 index 00000000..e51ac419 --- /dev/null +++ b/src/vscode/VSCodePlugin.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { LAYOUT_REVISION_KEY } from '../constants.js'; +import { readLayoutFromFile } from '../layoutPersistence.js'; +import type { + IAgentProvider, + IMessageBridge, + IPixelAgentsPlugin, + IRuntimeUI, +} from '../plugin/types.js'; +import { VSCodeAgentProvider } from './VSCodeAgentProvider.js'; +import { VSCodeMessageBridge } from './VSCodeMessageBridge.js'; +import { VSCodeRuntimeUI } from './VSCodeRuntimeUI.js'; + +export class VSCodePlugin implements IPixelAgentsPlugin, vscode.WebviewViewProvider { + readonly name = 'vscode'; + readonly version = '1.0.0'; + + readonly agentProvider: IAgentProvider; + readonly messageBridge: IMessageBridge; + readonly runtimeUI: IRuntimeUI; + + private readonly bridge: VSCodeMessageBridge; + + constructor(private readonly context: vscode.ExtensionContext) { + this.bridge = new VSCodeMessageBridge(); + this.messageBridge = this.bridge; + this.agentProvider = new VSCodeAgentProvider(); + this.runtimeUI = new VSCodeRuntimeUI(context); + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.bridge.init(webviewView, this.context.extensionUri); + } + + getAssetsRoot(): string | undefined { + const bundled = path.join(this.context.extensionUri.fsPath, 'dist', 'assets'); + if (fs.existsSync(bundled)) return path.join(this.context.extensionUri.fsPath, 'dist'); + return undefined; + } + + /** Dev utility: export current layout as a new versioned default */ + exportDefaultLayout(): void { + const layout = readLayoutFromFile(); + if (!layout) { + void vscode.window.showWarningMessage('Pixel Agents: No saved layout found.'); + return; + } + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + void vscode.window.showErrorMessage('Pixel Agents: No workspace folder found.'); + return; + } + const assetsDir = path.join(workspaceRoot, 'webview-ui', 'public', 'assets'); + + let maxRevision = 0; + if (fs.existsSync(assetsDir)) { + for (const file of fs.readdirSync(assetsDir)) { + const match = /^default-layout-(\d+)\.json$/.exec(file); + if (match) { + maxRevision = Math.max(maxRevision, parseInt(match[1], 10)); + } + } + } + const nextRevision = maxRevision + 1; + layout[LAYOUT_REVISION_KEY] = nextRevision; + + const targetPath = path.join(assetsDir, `default-layout-${nextRevision}.json`); + fs.writeFileSync(targetPath, JSON.stringify(layout, null, 2), 'utf-8'); + void vscode.window.showInformationMessage( + `Pixel Agents: Default layout exported as revision ${nextRevision} to ${targetPath}`, + ); + } + + dispose(): void { + this.bridge.dispose(); + this.agentProvider.dispose(); + } +} diff --git a/src/vscode/VSCodeRuntimeUI.ts b/src/vscode/VSCodeRuntimeUI.ts new file mode 100644 index 00000000..bf0f5dee --- /dev/null +++ b/src/vscode/VSCodeRuntimeUI.ts @@ -0,0 +1,73 @@ +import * as vscode from 'vscode'; + +import type { + Event, + IDisposable, + IRuntimeUI, + OpenDialogOptions, + SaveDialogOptions, + WorkspaceFolder, +} from '../plugin/types.js'; + +export class VSCodeRuntimeUI implements IRuntimeUI { + constructor(private readonly context: vscode.ExtensionContext) {} + + async showOpenDialog(options?: OpenDialogOptions): Promise { + const uris = await vscode.window.showOpenDialog({ + filters: options?.filters, + canSelectMany: options?.canSelectMany ?? false, + }); + if (!uris || uris.length === 0) return null; + return uris.map((u) => u.fsPath); + } + + async showSaveDialog(options?: SaveDialogOptions): Promise { + const uri = await vscode.window.showSaveDialog({ + filters: options?.filters, + defaultUri: options?.defaultPath ? vscode.Uri.file(options.defaultPath) : undefined, + }); + return uri?.fsPath ?? null; + } + + async showInformationMessage(message: string): Promise { + await vscode.window.showInformationMessage(message); + } + + async showErrorMessage(message: string): Promise { + await vscode.window.showErrorMessage(message); + } + + async openPath(fsPath: string): Promise { + await vscode.env.openExternal(vscode.Uri.file(fsPath)); + } + + getWorkspaceFolders(): WorkspaceFolder[] { + return (vscode.workspace.workspaceFolders ?? []).map((f) => ({ + name: f.name, + path: f.uri.fsPath, + })); + } + + onWorkspaceFoldersChanged: Event = (handler): IDisposable => { + const disposable = vscode.workspace.onDidChangeWorkspaceFolders(() => { + handler(this.getWorkspaceFolders()); + }); + return { dispose: () => disposable.dispose() }; + }; + + getState(key: string): T | undefined { + return this.context.workspaceState.get(key); + } + + async setState(key: string, value: T): Promise { + await this.context.workspaceState.update(key, value); + } + + getGlobalState(key: string): T | undefined { + return this.context.globalState.get(key); + } + + async setGlobalState(key: string, value: T): Promise { + await this.context.globalState.update(key, value); + } +} diff --git a/webview-ui/index.html b/webview-ui/index.html index 74df0e60..a90626ff 100644 --- a/webview-ui/index.html +++ b/webview-ui/index.html @@ -2,7 +2,7 @@ - + webview-ui