diff --git a/README.md b/README.md index b6698601..37ecad31 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,50 @@ # Pixel Agents -A VS Code extension that turns your AI coding agents into animated pixel art characters in a virtual office. +A VS Code extension that turns your coding agents and terminals into animated pixel art characters in a virtual office. -Each Claude Code terminal you open spawns a character that walks around, sits at desks, and visually reflects what the agent is doing — typing when writing code, reading when searching files, waiting when it needs your attention. - -This is the source code for the free [Pixel Agents extension for VS Code](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents) — you can install it directly from the marketplace with the full furniture catalog included. +Every terminal you open — whether it's Claude Code, Opencode, or a plain VS Code shell — can spawn a character that walks around, sits at desks, and visually shows what it's doing in real time. +This is a fork of the [Pixel Agents extension](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents) by [pablodelucca](https://github.com/pablodelucca/pixel-agents), with added support for multiple agent types beyond Claude Code. ![Pixel Agents screenshot](webview-ui/public/Screenshot.jpg) ## Features -- **One agent, one character** — every Claude Code terminal gets its own animated character -- **Live activity tracking** — characters animate based on what the agent is actually doing (writing, reading, running commands) +- **Multi-agent support** — connect Claude Code, Opencode, or any VS Code terminal as an animated character +- **Live activity tracking** — characters animate based on real activity (writing, reading, running commands) +- **VS Code Terminal tracking** — any shell command (`dir`, `npm install`, `ping`, etc.) makes the character animate via VS Code's shell integration API - **Office layout editor** — design your office with floors, walls, and furniture using a built-in editor - **Speech bubbles** — visual indicators when an agent is waiting for input or needs permission - **Sound notifications** — optional chime when an agent finishes its turn - **Sub-agent visualization** — Task tool sub-agents spawn as separate characters linked to their parent - **Persistent layouts** — your office design is saved and shared across VS Code windows -- **Diverse characters** — 6 diverse characters. These are based on the amazing work of [JIK-A-4, Metro City](https://jik-a-4.itch.io/metrocity-free-topdown-character-pack). +- **Diverse characters** — 6 diverse characters based on the amazing work of [JIK-A-4, Metro City](https://jik-a-4.itch.io/metrocity-free-topdown-character-pack)

Pixel Agents characters

+## Supported Agent Types + +| Agent Type | How It Works | Activity Detection | +|---|---|---| +| **Claude Code** | Launches `claude` CLI in a terminal | Watches JSONL transcript files — shows specific tool status (Reading, Writing, Running, etc.) | +| **Opencode** | Launches `opencode` CLI in a terminal | Watches JSONL transcript files — similar to Claude Code | +| **VS Code Terminal** | Creates a new shell or adopts an existing terminal | Uses VS Code shell integration — detects command start/end, shows the command being run | + ## Requirements -- VS Code 1.109.0 or later -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured +- VS Code 1.107.0 or later +- **For Claude Code agents:** [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed +- **For Opencode agents:** [Opencode](https://github.com/nichochar/opencode) installed +- **For VS Code Terminal agents:** No extra requirements — works with any terminal ## Getting Started -If you just want to use Pixel Agents, the easiest way is to download the [VS Code extension](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents). If you want to play with the code, develop, or contribute, then: - ### Install from source ```bash -git clone https://github.com/pablodelucca/pixel-agents.git +git clone https://github.com/Drepheus/pixel-agents.git cd pixel-agents npm install cd webview-ui && npm install && cd .. @@ -48,11 +56,24 @@ Then press **F5** in VS Code to launch the Extension Development Host. ### Usage 1. Open the **Pixel Agents** panel (it appears in the bottom panel area alongside your terminal) -2. Click **+ Agent** to spawn a new Claude Code terminal and its character -3. Start coding with Claude — watch the character react in real time -4. Click a character to select it, then click a seat to reassign it +2. Click **+ Agent** and choose an agent type: + - **Claude Code** — spawns a Claude Code CLI terminal + - **Opencode** — spawns an Opencode CLI terminal + - **VS Code Terminal** — spawns a new shell (or use **Adopt Terminal** to connect an existing one) +3. Run commands or start coding — watch the character react in real time +4. Click a character to select it, then click a chair to assign it a seat 5. Click **Layout** to open the office editor and customize your space +### VS Code Terminal Agents + +VS Code Terminal agents work with any shell. They detect commands using VS Code's built-in shell integration: + +- When you run a command (e.g., `dir`, `npm install`, `git status`), the character walks to its desk and starts typing +- When the command finishes, the character goes back to idle wandering +- The status overlay shows the actual command being run (e.g., "Running: npm install") + +**Note:** Shell integration must be active in the terminal (it's enabled by default in modern VS Code). + ## Layout Editor The built-in editor lets you design your office: @@ -67,21 +88,20 @@ The grid is expandable up to 64×64 tiles. Click the ghost border outside the cu ### Office Assets -The office tileset used in this project and available via the extension is **[Office Interior Tileset (16x16)](https://donarg.itch.io/officetileset)** by **Donarg**, available on itch.io for **$2 USD**. +The office tileset used in this project is **[Office Interior Tileset (16x16)](https://donarg.itch.io/officetileset)** by **Donarg**, available on itch.io for **$2 USD**. -This is the only part of the project that is not freely available. The tileset is not included in this repository due to its license. To use Pixel Agents locally with the full set of office furniture and decorations, purchase the tileset and run the asset import pipeline: +The tileset is not included in this repository due to its license. To use the full furniture catalog, purchase the tileset and run: ```bash npm run import-tileset ``` -Fair warning: the import pipeline is not exactly straightforward — the out-of-the-box tileset assets aren't the easiest to work with, and while I've done my best to make the process as smooth as possible, it may require some manual tweaking. If you have experience creating pixel art office assets and would like to contribute freely usable tilesets for the community, that would be hugely appreciated. - -The extension will still work without the tileset — you'll get the default characters and basic layout, but the full furniture catalog requires the imported assets. +The extension works without the tileset — you get default characters and basic layout, but the full furniture catalog requires the imported assets. ## How It Works -Pixel Agents watches Claude Code's JSONL transcript files to track what each agent is doing. When an agent uses a tool (like writing a file or running a command), the extension detects it and updates the character's animation accordingly. No modifications to Claude Code are needed — it's purely observational. +- **Claude Code / Opencode:** Watches JSONL transcript files to track tool usage. When an agent reads a file, writes code, or runs a command, the character animates accordingly. +- **VS Code Terminal:** Hooks into `onDidStartTerminalShellExecution` and `onDidEndTerminalShellExecution` to detect when commands are running. The webview runs a lightweight game loop with canvas rendering, BFS pathfinding, and a character state machine (idle → walk → type/read). Everything is pixel-perfect at integer zoom levels. @@ -92,41 +112,14 @@ The webview runs a lightweight game loop with canvas rendering, BFS pathfinding, ## Known Limitations -- **Agent-terminal sync** — the way agents are connected to Claude Code terminal instances is not super robust and sometimes desyncs, especially when terminals are rapidly opened/closed or restored across sessions. -- **Heuristic-based status detection** — Claude Code's JSONL transcript format does not provide clear signals for when an agent is waiting for user input or when it has finished its turn. The current detection is based on heuristics (idle timers, turn-duration events) and often misfires — agents may briefly show the wrong status or miss transitions. -- **Windows-only testing** — the extension has only been tested on Windows 11. It may work on macOS or Linux, but there could be unexpected issues with file watching, paths, or terminal behavior on those platforms. - -## Roadmap - -There are several areas where contributions would be very welcome: - -- **Improve agent-terminal reliability** — more robust connection and sync between characters and Claude Code instances -- **Better status detection** — find or propose clearer signals for agent state transitions (waiting, done, permission needed) -- **Community assets** — freely usable pixel art tilesets or characters that anyone can use without purchasing third-party assets -- **Agent creation and definition** — define agents with custom skills, system prompts, names, and skins before launching them -- **Desks as directories** — click on a desk to select a working directory, drag and drop agents or click-to-assign to move them to specific desks/projects -- **Claude Code agent teams** — native support for [agent teams](https://code.claude.com/docs/en/agent-teams), visualizing multi-agent coordination and communication -- **Git worktree support** — agents working in different worktrees to avoid conflict from parallel work on the same files -- **Support for other agentic frameworks** — [OpenCode](https://github.com/nichochar/opencode), or really any kind of agentic experiment you'd want to run inside a pixel art interface (see [simile.ai](https://simile.ai/) for inspiration) - -If any of these interest you, feel free to open an issue or submit a PR. - -## Contributions - -See [CONTRIBUTORS.md](CONTRIBUTORS.md) for instructions on how to contribute to this project. - -Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating. - -## Supporting the Project +- **Shell integration required for VS Code Terminal agents** — if shell integration isn't active in a terminal, command detection won't work +- **Agent-terminal sync** — connecting agents to terminal instances can desync when terminals are rapidly opened/closed or restored across sessions +- **Heuristic-based status detection** — Claude Code/Opencode status detection uses timers and heuristics that can occasionally misfire +- **Windows-focused testing** — primarily tested on Windows 11; may work on macOS/Linux but could have issues -If you find Pixel Agents useful, consider supporting its development: +## Upstream - - GitHub Sponsors - - - Ko-fi - +This is a fork of [pablodelucca/pixel-agents](https://github.com/pablodelucca/pixel-agents). The multi-agent support (Opencode, VS Code Terminal, terminal activity tracking) was added in this fork. ## License diff --git a/package-lock.json b/package-lock.json index 68c701a2..a7cd8900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "pixel-agents", - "version": "0.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixel-agents", - "version": "0.0.1", + "version": "1.0.2", + "license": "MIT", "devDependencies": { "@anthropic-ai/sdk": "^0.74.0", "@types/node": "22.x", "@types/pngjs": "^6.0.5", - "@types/vscode": "^1.109.0", + "@types/vscode": "^1.107.0", "esbuild": "^0.27.2", "eslint": "^9.39.2", "npm-run-all": "^4.1.5", @@ -21,7 +22,7 @@ "typescript-eslint": "^8.54.0" }, "engines": { - "vscode": "^1.109.0" + "vscode": "^1.107.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -827,7 +828,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1019,7 +1019,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1628,7 +1627,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3825,7 +3823,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3970,7 +3967,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index fe78bd46..af24c03c 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -2,9 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; -import type { AgentState } from './types.js'; +import type { AgentState, AgentType } from './types.js'; import { launchNewTerminal, + adoptExistingTerminal, removeAgent, restoreAgents, persistAgents, @@ -17,6 +18,7 @@ import { loadFurnitureAssets, sendAssetsToWebview, loadFloorTiles, sendFloorTile import { WORKSPACE_KEY_AGENT_SEATS, GLOBAL_KEY_SOUND_ENABLED } from './constants.js'; import { writeLayoutToFile, readLayoutFromFile, watchLayoutFile } from './layoutPersistence.js'; import type { LayoutWatcher } from './layoutPersistence.js'; +import { startTerminalActivityTracking, cleanupTerminalActivity } from './terminalActivityTracker.js'; export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { nextAgentId = { current: 1 }; @@ -42,6 +44,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { // Cross-window layout sync layoutWatcher: LayoutWatcher | null = null; + // Terminal activity tracking for vscode-terminal agents + terminalActivityDisposable: vscode.Disposable | null = null; + constructor(private readonly context: vscode.ExtensionContext) {} private get extensionUri(): vscode.Uri { @@ -61,8 +66,17 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { webviewView.webview.options = { enableScripts: true }; webviewView.webview.html = getWebviewContent(webviewView.webview, this.extensionUri); + // Start terminal activity tracking for vscode-terminal agents + if (!this.terminalActivityDisposable) { + this.terminalActivityDisposable = startTerminalActivityTracking( + this.agents, + () => this.webview, + ); + } + webviewView.webview.onDidReceiveMessage(async (message) => { if (message.type === 'openClaude') { + const agentType = (message.agentType as AgentType) || 'claude-code'; await launchNewTerminal( this.nextAgentId, this.nextTerminalIndex, this.agents, this.activeAgentId, this.knownJsonlFiles, @@ -70,6 +84,13 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.jsonlPollTimers, this.projectScanTimer, this.webview, this.persistAgents, message.folderPath as string | undefined, + agentType, + ); + } else if (message.type === 'adoptTerminal') { + await adoptExistingTerminal( + this.nextAgentId, + this.agents, this.activeAgentId, + this.webview, this.persistAgents, ); } else if (message.type === 'focusAgent') { const agent = this.agents.get(message.id); @@ -283,8 +304,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { if (agent.terminalRef === closed) { if (this.activeAgentId.current === id) { this.activeAgentId.current = null; - } - removeAgent( + } cleanupTerminalActivity(id); removeAgent( id, this.agents, this.fileWatchers, this.pollingTimers, this.waitingTimers, this.permissionTimers, this.jsonlPollTimers, this.persistAgents, @@ -322,6 +342,8 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } dispose() { + this.terminalActivityDisposable?.dispose(); + this.terminalActivityDisposable = null; this.layoutWatcher?.dispose(); this.layoutWatcher = null; for (const id of [...this.agents.keys()]) { diff --git a/src/agentManager.ts b/src/agentManager.ts index 4c53af84..1686df11 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -1,19 +1,23 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import * as vscode from 'vscode'; -import type { AgentState, PersistedAgent } from './types.js'; +import type { AgentState, AgentType, PersistedAgent } from './types.js'; import { cancelWaitingTimer, cancelPermissionTimer } from './timerManager.js'; import { startFileWatching, readNewLines, ensureProjectScan } from './fileWatcher.js'; -import { JSONL_POLL_INTERVAL_MS, TERMINAL_NAME_PREFIX, WORKSPACE_KEY_AGENTS, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; +import { JSONL_POLL_INTERVAL_MS, WORKSPACE_KEY_AGENTS, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; import { migrateAndLoadLayout } from './layoutPersistence.js'; +import { getAgentTypeConfig } from './agentTypeRegistry.js'; -export function getProjectDirPath(cwd?: string): string | null { +/** + * Get the project directory path for a given agent type and workspace. + * Falls back to Claude Code behavior for backwards compatibility. + */ +export function getProjectDirPath(cwd?: string, agentType: AgentType = 'claude-code'): 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}`); + const config = getAgentTypeConfig(agentType); + const projectDir = config.getProjectDir(workspacePath); + console.log(`[Pixel Agents] Project dir (${agentType}): ${workspacePath} → ${projectDir}`); return projectDir; } @@ -32,35 +36,66 @@ export async function launchNewTerminal( webview: vscode.Webview | undefined, persistAgents: () => void, folderPath?: string, + agentType: AgentType = 'claude-code', ): Promise { + const config = getAgentTypeConfig(agentType); 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}`, + name: `${config.terminalPrefix} #${idx}`, cwd, }); terminal.show(); const sessionId = crypto.randomUUID(); - terminal.sendText(`claude --session-id ${sessionId}`); + const launchCmd = config.launchCommand(sessionId); + if (launchCmd) { + terminal.sendText(launchCmd); + } + + const projectDir = getProjectDirPath(cwd, agentType); + const folderName = isMultiRoot && cwd ? path.basename(cwd) : undefined; - const projectDir = getProjectDirPath(cwd); - if (!projectDir) { - console.log(`[Pixel Agents] No project dir, cannot track agent`); + // For agent types without transcript files (e.g. vscode-terminal), create a basic agent + if (!config.hasTranscriptFiles || !projectDir) { + const id = nextAgentIdRef.current++; + const agent: AgentState = { + id, + agentType, + terminalRef: terminal, + projectDir: 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, + }; + agents.set(id, agent); + activeAgentIdRef.current = id; + persistAgents(); + console.log(`[Pixel Agents] Agent ${id} (${agentType}): created for terminal ${terminal.name} (no transcript tracking)`); + webview?.postMessage({ type: 'agentCreated', id, folderName, agentType }); return; } - // Pre-register expected JSONL file so project scan won't treat it as a /clear file - const expectedFile = path.join(projectDir, `${sessionId}.jsonl`); + // Pre-register expected transcript file so project scan won't treat it as a /clear file + const expectedFile = config.getTranscriptFile(projectDir, sessionId); knownJsonlFiles.add(expectedFile); - // Create agent immediately (before JSONL file exists) + // Create agent immediately (before transcript file exists) const id = nextAgentIdRef.current++; - const folderName = isMultiRoot && cwd ? path.basename(cwd) : undefined; const agent: AgentState = { id, + agentType, terminalRef: terminal, projectDir, jsonlFile: expectedFile, @@ -80,20 +115,22 @@ export async function launchNewTerminal( 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 }); + console.log(`[Pixel Agents] Agent ${id} (${agentType}): created for terminal ${terminal.name}`); + webview?.postMessage({ type: 'agentCreated', id, folderName, agentType }); - ensureProjectScan( - projectDir, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, - nextAgentIdRef, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, - webview, persistAgents, - ); + if (config.hasProjectScan) { + ensureProjectScan( + projectDir, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, + nextAgentIdRef, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, + webview, persistAgents, + ); + } - // Poll for the specific JSONL file to appear + // Poll for the specific transcript 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)}`); + console.log(`[Pixel Agents] Agent ${id}: found transcript file ${path.basename(agent.jsonlFile)}`); clearInterval(pollTimer); jsonlPollTimers.delete(id); startFileWatching(id, agent.jsonlFile, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); @@ -104,6 +141,71 @@ export async function launchNewTerminal( jsonlPollTimers.set(id, pollTimer); } +/** + * Adopt an existing VS Code terminal as an agent (for vscode-terminal type). + * Allows users to connect any running terminal to the pixel agents display. + */ +export async function adoptExistingTerminal( + nextAgentIdRef: { current: number }, + agents: Map, + activeAgentIdRef: { current: number | null }, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): Promise { + // Get all terminals not already owned by an agent + const ownedTerminals = new Set(); + for (const agent of agents.values()) { + ownedTerminals.add(agent.terminalRef); + } + const availableTerminals = vscode.window.terminals.filter(t => !ownedTerminals.has(t)); + + if (availableTerminals.length === 0) { + vscode.window.showInformationMessage('Pixel Agents: No unassigned terminals found. Open a terminal first.'); + return; + } + + const items = availableTerminals.map(t => ({ + label: t.name, + terminal: t, + })); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a terminal to connect as an agent', + }); + if (!picked) return; + + const terminal = picked.terminal; + const folders = vscode.workspace.workspaceFolders; + const isMultiRoot = !!(folders && folders.length > 1); + + const id = nextAgentIdRef.current++; + const folderName = isMultiRoot ? path.basename(folders?.[0]?.uri.fsPath || '') : undefined; + const agent: AgentState = { + id, + agentType: 'vscode-terminal', + 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, + folderName, + }; + + agents.set(id, agent); + activeAgentIdRef.current = id; + persistAgents(); + console.log(`[Pixel Agents] Agent ${id} (vscode-terminal): adopted terminal "${terminal.name}"`); + webview?.postMessage({ type: 'agentCreated', id, folderName, agentType: 'vscode-terminal' }); +} + export function removeAgent( agentId: number, agents: Map, @@ -147,6 +249,7 @@ export function persistAgents( for (const agent of agents.values()) { persisted.push({ id: agent.id, + agentType: agent.agentType, terminalName: agent.terminalRef.name, jsonlFile: agent.jsonlFile, projectDir: agent.projectDir, @@ -184,8 +287,11 @@ export function restoreAgents( const terminal = liveTerminals.find(t => t.name === p.terminalName); if (!terminal) continue; + const agentType = p.agentType || 'claude-code'; // backwards compat + const agent: AgentState = { id: p.id, + agentType, terminalRef: terminal, projectDir: p.projectDir, jsonlFile: p.jsonlFile, @@ -203,42 +309,48 @@ export function restoreAgents( }; agents.set(p.id, agent); - knownJsonlFiles.add(p.jsonlFile); - console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); + if (p.jsonlFile) { + knownJsonlFiles.add(p.jsonlFile); + } + console.log(`[Pixel Agents] Restored agent ${p.id} (${agentType}) → terminal "${p.terminalName}"`); if (p.id > maxId) maxId = p.id; - // Extract terminal index from name like "Claude Code #3" + // Extract terminal index from name like "Claude Code #3" or "Opencode #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 */ } + restoredProjectDir = p.projectDir || restoredProjectDir; + + // Only set up file watching for agent types that have transcript files + const typeConfig = getAgentTypeConfig(agentType); + if (typeConfig.hasTranscriptFiles && p.jsonlFile) { + // Start file watching if transcript 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 transcript 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 @@ -277,12 +389,14 @@ export function sendExistingAgents( // Include persisted palette/seatId from separate key const agentMeta = context.workspaceState.get>(WORKSPACE_KEY_AGENT_SEATS, {}); - // Include folderName per agent + // Include folderName and agentType per agent const folderNames: Record = {}; + const agentTypes: Record = {}; for (const [id, agent] of agents) { if (agent.folderName) { folderNames[id] = agent.folderName; } + agentTypes[id] = agent.agentType; } console.log(`[Pixel Agents] sendExistingAgents: agents=${JSON.stringify(agentIds)}, meta=${JSON.stringify(agentMeta)}`); @@ -291,6 +405,7 @@ export function sendExistingAgents( agents: agentIds, agentMeta, folderNames, + agentTypes, }); sendCurrentAgentStatuses(agents, webview); diff --git a/src/agentTypeRegistry.ts b/src/agentTypeRegistry.ts new file mode 100644 index 00000000..fdf5549c --- /dev/null +++ b/src/agentTypeRegistry.ts @@ -0,0 +1,108 @@ +import * as path from 'path'; +import * as os from 'os'; +import type { AgentType } from './types.js'; +import { AGENT_TERMINAL_PREFIXES } from './constants.js'; + +export interface AgentTypeConfig { + /** Human-readable label */ + label: string; + /** Terminal name prefix */ + terminalPrefix: string; + /** Command to launch in the terminal (receives sessionId as {{SESSION_ID}} placeholder) */ + launchCommand: (sessionId: string) => string; + /** Resolves the project/session directory for transcript files. Returns null if not applicable. */ + getProjectDir: (workspacePath: string) => string | null; + /** Resolves the expected transcript file path given projectDir and sessionId */ + getTranscriptFile: (projectDir: string, sessionId: string) => string; + /** Whether this agent type uses file-based transcript monitoring */ + hasTranscriptFiles: boolean; + /** Whether to run the project-level scan for /clear detection */ + hasProjectScan: boolean; + /** File extension for transcript files */ + transcriptExtension: string; +} + +/** + * Claude Code: monitors ~/.claude/projects//.jsonl + */ +const claudeCodeConfig: AgentTypeConfig = { + label: 'Claude Code', + terminalPrefix: AGENT_TERMINAL_PREFIXES['claude-code'], + launchCommand: (sessionId: string) => `claude --session-id ${sessionId}`, + getProjectDir: (workspacePath: string) => { + const dirName = workspacePath.replace(/[^a-zA-Z0-9-]/g, '-'); + return path.join(os.homedir(), '.claude', 'projects', dirName); + }, + getTranscriptFile: (projectDir: string, sessionId: string) => { + return path.join(projectDir, `${sessionId}.jsonl`); + }, + hasTranscriptFiles: true, + hasProjectScan: true, + transcriptExtension: '.jsonl', +}; + +/** + * Opencode: monitors ~/.local/share/opencode/sessions//.jsonl + * Opencode is an open-source AI coding agent. It stores session data + * under its own directory structure. We also support a custom env var + * OPENCODE_HOME for overriding the data location. + */ +const opencodeConfig: AgentTypeConfig = { + label: 'Opencode', + terminalPrefix: AGENT_TERMINAL_PREFIXES['opencode'], + launchCommand: (_sessionId: string) => `opencode`, + getProjectDir: (workspacePath: string) => { + // Opencode stores project data under its home directory + const opencodeHome = process.env.OPENCODE_HOME || + (os.platform() === 'win32' + ? path.join(os.homedir(), '.opencode') + : path.join(os.homedir(), '.local', 'share', 'opencode')); + const dirName = workspacePath.replace(/[^a-zA-Z0-9-]/g, '-'); + return path.join(opencodeHome, 'sessions', dirName); + }, + getTranscriptFile: (projectDir: string, sessionId: string) => { + return path.join(projectDir, `${sessionId}.jsonl`); + }, + hasTranscriptFiles: true, + hasProjectScan: true, + transcriptExtension: '.jsonl', +}; + +/** + * VSCode Terminal: basic presence tracking without file-based transcripts. + * Can connect to an existing terminal or create a new shell. + */ +const vscodeTerminalConfig: AgentTypeConfig = { + label: 'VS Code Terminal', + terminalPrefix: AGENT_TERMINAL_PREFIXES['vscode-terminal'], + launchCommand: (_sessionId: string) => '', // No specific command — just a shell + getProjectDir: (_workspacePath: string) => null, // No project dir needed + getTranscriptFile: (_projectDir: string, _sessionId: string) => '', + hasTranscriptFiles: false, + hasProjectScan: false, + transcriptExtension: '', +}; + +const AGENT_TYPE_CONFIGS: Record = { + 'claude-code': claudeCodeConfig, + 'opencode': opencodeConfig, + 'vscode-terminal': vscodeTerminalConfig, +}; + +/** + * Get the configuration for an agent type. + */ +export function getAgentTypeConfig(agentType: AgentType): AgentTypeConfig { + return AGENT_TYPE_CONFIGS[agentType]; +} + +/** + * Get all available agent types and their labels. + */ +export function getAvailableAgentTypes(): Array<{ type: AgentType; label: string }> { + return [ + { type: 'claude-code', label: 'Claude Code' }, + { type: 'opencode', label: 'Opencode' }, + { type: 'vscode-terminal', label: 'VS Code Terminal' }, + ]; +} diff --git a/src/constants.ts b/src/constants.ts index 5e95c166..ae3e2435 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,3 +40,12 @@ export const WORKSPACE_KEY_AGENTS = 'pixel-agents.agents'; export const WORKSPACE_KEY_AGENT_SEATS = 'pixel-agents.agentSeats'; export const WORKSPACE_KEY_LAYOUT = 'pixel-agents.layout'; export const TERMINAL_NAME_PREFIX = 'Claude Code'; +export const TERMINAL_NAME_PREFIX_OPENCODE = 'Opencode'; +export const TERMINAL_NAME_PREFIX_VSCODE = 'Terminal Agent'; + +/** Map of agent type to terminal name prefix */ +export const AGENT_TERMINAL_PREFIXES: Record = { + 'claude-code': TERMINAL_NAME_PREFIX, + 'opencode': TERMINAL_NAME_PREFIX_OPENCODE, + 'vscode-terminal': TERMINAL_NAME_PREFIX_VSCODE, +}; diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index 332ceb16..88c3c290 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import type { AgentState } from './types.js'; import { cancelWaitingTimer, cancelPermissionTimer, clearAgentActivity } from './timerManager.js'; import { processTranscriptLine } from './transcriptParser.js'; +import { processOpencodeTranscriptLine } from './opencodeParser.js'; import { FILE_WATCHER_POLL_INTERVAL_MS, PROJECT_SCAN_INTERVAL_MS } from './constants.js'; export function startFileWatching( @@ -83,7 +84,13 @@ export function readNewLines( for (const line of lines) { if (!line.trim()) continue; - processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); + // Dispatch to the appropriate parser based on agent type + const agentType = agent.agentType || 'claude-code'; + if (agentType === 'opencode') { + processOpencodeTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); + } else { + processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); + } } } catch (e) { console.log(`[Pixel Agents] Read error for agent ${agentId}: ${e}`); @@ -197,6 +204,7 @@ function adoptTerminalForFile( const id = nextAgentIdRef.current++; const agent: AgentState = { id, + agentType: 'claude-code', // Adopted terminals from project scan are always Claude Code terminalRef: terminal, projectDir, jsonlFile, diff --git a/src/opencodeParser.ts b/src/opencodeParser.ts new file mode 100644 index 00000000..270b6cab --- /dev/null +++ b/src/opencodeParser.ts @@ -0,0 +1,282 @@ +/** + * Opencode transcript parser. + * + * Opencode is an open-source AI coding assistant that can work with multiple + * LLM providers. This parser handles its JSONL session transcript format. + * + * Opencode may store session data in different formats depending on version, + * so this parser is designed to be lenient and handle multiple variants: + * + * 1. If Opencode produces Claude-compatible JSONL (assistant/user/system records), + * we delegate to the standard Claude Code parser for those records. + * 2. For Opencode-specific record formats, we translate them to the standard + * agent events (tool start/done, status changes). + * + * Supported Opencode JSONL record types: + * - { role: "assistant", content: [...] } — assistant messages with tool calls + * - { role: "user", content: [...] } — user messages / tool results + * - { type: "tool_call", name: "...", id: "...", arguments: {...} } — tool invocations + * - { type: "tool_result", id: "...", output: "..." } — tool completions + * - { type: "status", status: "thinking"|"idle"|"error" } — status changes + * - { type: "event", event: "turn_end" } — turn boundaries + */ + +import * as path from 'path'; +import type * as vscode from 'vscode'; +import type { AgentState } from './types.js'; +import { + cancelWaitingTimer, + startWaitingTimer, + clearAgentActivity, + startPermissionTimer, + cancelPermissionTimer, +} from './timerManager.js'; +import { + TOOL_DONE_DELAY_MS, + TEXT_IDLE_DELAY_MS, + BASH_COMMAND_DISPLAY_MAX_LENGTH, + TASK_DESCRIPTION_DISPLAY_MAX_LENGTH, +} from './constants.js'; +import { processTranscriptLine, PERMISSION_EXEMPT_TOOLS } from './transcriptParser.js'; + +/** + * Format a tool status string for Opencode tools. + * Maps Opencode tool names to human-readable status strings. + */ +function formatOpencodeToolStatus(toolName: string, args: Record): string { + const base = (p: unknown) => typeof p === 'string' ? path.basename(p) : ''; + const normalized = toolName.toLowerCase(); + + // Map common Opencode tool names to statuses + if (normalized.includes('read') || normalized.includes('file_read')) { + return `Reading ${base(args.path || args.file_path || args.file)}`; + } + if (normalized.includes('write') || normalized.includes('file_write')) { + return `Writing ${base(args.path || args.file_path || args.file)}`; + } + if (normalized.includes('edit') || normalized.includes('patch') || normalized.includes('file_edit')) { + return `Editing ${base(args.path || args.file_path || args.file)}`; + } + if (normalized.includes('bash') || normalized.includes('shell') || normalized.includes('exec') || normalized.includes('command')) { + const cmd = (args.command as string) || (args.cmd as string) || ''; + return `Running: ${cmd.length > BASH_COMMAND_DISPLAY_MAX_LENGTH ? cmd.slice(0, BASH_COMMAND_DISPLAY_MAX_LENGTH) + '\u2026' : cmd}`; + } + if (normalized.includes('search') || normalized.includes('grep') || normalized.includes('find')) { + return 'Searching code'; + } + if (normalized.includes('glob') || normalized.includes('list') || normalized.includes('ls')) { + return 'Searching files'; + } + if (normalized.includes('web') || normalized.includes('fetch') || normalized.includes('http')) { + return 'Fetching web content'; + } + if (normalized.includes('task') || normalized.includes('subtask') || normalized.includes('agent')) { + const desc = typeof args.description === 'string' ? args.description : ''; + return desc ? `Subtask: ${desc.length > TASK_DESCRIPTION_DISPLAY_MAX_LENGTH ? desc.slice(0, TASK_DESCRIPTION_DISPLAY_MAX_LENGTH) + '\u2026' : desc}` : 'Running subtask'; + } + return `Using ${toolName}`; +} + +/** + * Process a single JSONL transcript line from an Opencode session. + */ +export function processOpencodeTranscriptLine( + agentId: number, + line: string, + agents: Map, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { + const agent = agents.get(agentId); + if (!agent) return; + + try { + const record = JSON.parse(line); + + // ── Format 1: Claude-compatible records (type: assistant/user/system) ── + // If the record looks like Claude Code format, delegate to the standard parser. + if (record.type === 'assistant' || record.type === 'user' || record.type === 'system' || record.type === 'progress') { + processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); + return; + } + + // ── Format 2: Role-based records (role: assistant/user) ── + if (record.role === 'assistant') { + const content = record.content; + if (Array.isArray(content)) { + const hasToolCall = content.some((b: Record) => + b.type === 'tool_use' || b.type === 'tool_call' || b.type === 'function_call' + ); + + if (hasToolCall) { + cancelWaitingTimer(agentId, waitingTimers); + agent.isWaiting = false; + agent.hadToolsInTurn = true; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + + let hasNonExemptTool = false; + for (const block of content) { + const blockType = block.type as string; + if ((blockType === 'tool_use' || blockType === 'tool_call' || blockType === 'function_call') && (block.id || block.call_id)) { + const toolId = (block.id || block.call_id) as string; + const toolName = (block.name || block.function?.name || '') as string; + const input = (block.input || block.arguments || block.function?.arguments || {}) as Record; + const status = formatOpencodeToolStatus(toolName, input); + + agent.activeToolIds.add(toolId); + agent.activeToolStatuses.set(toolId, status); + agent.activeToolNames.set(toolId, toolName); + + if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) { + hasNonExemptTool = true; + } + + webview?.postMessage({ + type: 'agentToolStart', + id: agentId, + toolId, + status, + }); + } + } + if (hasNonExemptTool) { + startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); + } + } else if (content.some((b: Record) => b.type === 'text') && !agent.hadToolsInTurn) { + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } + } else if (typeof content === 'string' && !agent.hadToolsInTurn) { + // Plain text assistant response + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } + return; + } + + if (record.role === 'user') { + const content = record.content; + if (Array.isArray(content)) { + const hasToolResult = content.some((b: Record) => + b.type === 'tool_result' || b.type === 'function_result' + ); + if (hasToolResult) { + for (const block of content) { + const resultId = (block.tool_use_id || block.call_id || block.id) as string | undefined; + if ((block.type === 'tool_result' || block.type === 'function_result') && resultId) { + agent.activeToolIds.delete(resultId); + agent.activeToolStatuses.delete(resultId); + agent.activeToolNames.delete(resultId); + + const toolId = resultId; + setTimeout(() => { + webview?.postMessage({ + type: 'agentToolDone', + id: agentId, + toolId, + }); + }, TOOL_DONE_DELAY_MS); + } + } + if (agent.activeToolIds.size === 0) { + agent.hadToolsInTurn = false; + } + } else { + // New user message — new turn + cancelWaitingTimer(agentId, waitingTimers); + clearAgentActivity(agent, agentId, permissionTimers, webview); + agent.hadToolsInTurn = false; + } + } else if (typeof content === 'string' && content.trim()) { + cancelWaitingTimer(agentId, waitingTimers); + clearAgentActivity(agent, agentId, permissionTimers, webview); + agent.hadToolsInTurn = false; + } + return; + } + + // ── Format 3: Standalone tool_call / tool_result records ── + if (record.type === 'tool_call' && (record.id || record.call_id)) { + cancelWaitingTimer(agentId, waitingTimers); + agent.isWaiting = false; + agent.hadToolsInTurn = true; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + + const toolId = (record.id || record.call_id) as string; + const toolName = (record.name || record.function || '') as string; + const input = (record.arguments || record.input || {}) as Record; + const status = formatOpencodeToolStatus(toolName, input); + + agent.activeToolIds.add(toolId); + agent.activeToolStatuses.set(toolId, status); + agent.activeToolNames.set(toolId, toolName); + + webview?.postMessage({ type: 'agentToolStart', id: agentId, toolId, status }); + startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); + return; + } + + if (record.type === 'tool_result' && (record.id || record.tool_use_id || record.call_id)) { + const toolId = (record.id || record.tool_use_id || record.call_id) as string; + agent.activeToolIds.delete(toolId); + agent.activeToolStatuses.delete(toolId); + agent.activeToolNames.delete(toolId); + + setTimeout(() => { + webview?.postMessage({ type: 'agentToolDone', id: agentId, toolId }); + }, TOOL_DONE_DELAY_MS); + + if (agent.activeToolIds.size === 0) { + agent.hadToolsInTurn = false; + } + return; + } + + // ── Format 4: Status / event records ── + if (record.type === 'status') { + const status = record.status as string; + if (status === 'idle' || status === 'done' || status === 'waiting') { + cancelWaitingTimer(agentId, waitingTimers); + cancelPermissionTimer(agentId, permissionTimers); + + if (agent.activeToolIds.size > 0) { + agent.activeToolIds.clear(); + agent.activeToolStatuses.clear(); + agent.activeToolNames.clear(); + webview?.postMessage({ type: 'agentToolsClear', id: agentId }); + } + + agent.isWaiting = true; + agent.permissionSent = false; + agent.hadToolsInTurn = false; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'waiting' }); + } else if (status === 'thinking' || status === 'running' || status === 'active') { + agent.isWaiting = false; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + } + return; + } + + if (record.type === 'event') { + if (record.event === 'turn_end' || record.event === 'turn_complete') { + cancelWaitingTimer(agentId, waitingTimers); + cancelPermissionTimer(agentId, permissionTimers); + + if (agent.activeToolIds.size > 0) { + agent.activeToolIds.clear(); + agent.activeToolStatuses.clear(); + agent.activeToolNames.clear(); + webview?.postMessage({ type: 'agentToolsClear', id: agentId }); + } + + agent.isWaiting = true; + agent.permissionSent = false; + agent.hadToolsInTurn = false; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'waiting' }); + } + return; + } + + } catch { + // Ignore malformed lines + } +} diff --git a/src/terminalActivityTracker.ts b/src/terminalActivityTracker.ts new file mode 100644 index 00000000..f5304605 --- /dev/null +++ b/src/terminalActivityTracker.ts @@ -0,0 +1,130 @@ +/** + * Terminal activity tracker for vscode-terminal agents. + * + * Listens to VS Code shell integration events to detect when commands + * are executed in terminals bound to vscode-terminal agents. + * + * - `onDidStartTerminalShellExecution` → send agentToolStart (typing animation) + * - `onDidEndTerminalShellExecution` → send agentToolDone (back to idle) + * + * Requires shell integration to be active in the terminal. + * Falls back to a no-op if shell integration is not available. + */ +import * as vscode from 'vscode'; +import type { AgentState } from './types.js'; + +/** Maps execution object → synthetic tool ID so we can match start/end */ +const executionToolIds = new WeakMap(); + +/** Per-agent counter for unique tool IDs */ +const toolCounters = new Map(); + +/** + * Find the vscode-terminal agent that owns the given terminal. + */ +function findVscodeTerminalAgent( + terminal: vscode.Terminal, + agents: Map, +): AgentState | undefined { + for (const agent of agents.values()) { + if (agent.agentType === 'vscode-terminal' && agent.terminalRef === terminal) { + return agent; + } + } + return undefined; +} + +function nextToolId(agentId: number): string { + const count = (toolCounters.get(agentId) || 0) + 1; + toolCounters.set(agentId, count); + return `terminal-cmd-${agentId}-${count}`; +} + +/** + * Start listening for terminal shell execution events. + * Returns a Disposable that should be pushed into the extension context subscriptions. + */ +export function startTerminalActivityTracking( + agents: Map, + webview: () => vscode.Webview | undefined, +): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + + // Command started → agent becomes active (typing animation) + disposables.push( + vscode.window.onDidStartTerminalShellExecution((e) => { + const agent = findVscodeTerminalAgent(e.terminal, agents); + if (!agent) { return; } + + const wv = webview(); + if (!wv) { return; } + + const toolId = nextToolId(agent.id); + executionToolIds.set(e.execution, toolId); + + // Extract command text for display (truncated) + const cmdText = e.execution.commandLine.value || 'Running command'; + const status = `Running: ${cmdText.length > 30 ? cmdText.slice(0, 27) + '...' : cmdText}`; + + // Update agent state for consistency + agent.activeToolIds.add(toolId); + agent.activeToolStatuses.set(toolId, status); + + wv.postMessage({ type: 'agentStatus', id: agent.id, status: 'active' }); + wv.postMessage({ + type: 'agentToolStart', + id: agent.id, + toolId, + status, + }); + + console.log(`[Pixel Agents] Terminal agent ${agent.id}: command started — ${cmdText}`); + }), + ); + + // Command ended → agent goes idle + disposables.push( + vscode.window.onDidEndTerminalShellExecution((e) => { + const agent = findVscodeTerminalAgent(e.terminal, agents); + if (!agent) { return; } + + const wv = webview(); + if (!wv) { return; } + + const toolId = executionToolIds.get(e.execution); + if (!toolId) { return; } + + executionToolIds.delete(e.execution); + + // Clean up agent state + agent.activeToolIds.delete(toolId); + agent.activeToolStatuses.delete(toolId); + + wv.postMessage({ + type: 'agentToolDone', + id: agent.id, + toolId, + }); + + // Only set waiting if no other commands are still running + if (agent.activeToolIds.size === 0) { + wv.postMessage({ + type: 'agentStatus', + id: agent.id, + status: 'waiting', + }); + } + + console.log(`[Pixel Agents] Terminal agent ${agent.id}: command ended (exit: ${e.exitCode})`); + }), + ); + + return vscode.Disposable.from(...disposables); +} + +/** + * Clean up activity tracking state for a removed agent. + */ +export function cleanupTerminalActivity(agentId: number): void { + toolCounters.delete(agentId); +} diff --git a/src/types.ts b/src/types.ts index 973afa3b..72def650 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,11 @@ import type * as vscode from 'vscode'; +/** Supported agent backend types */ +export type AgentType = 'claude-code' | 'opencode' | 'vscode-terminal'; + export interface AgentState { id: number; + agentType: AgentType; terminalRef: vscode.Terminal; projectDir: string; jsonlFile: string; @@ -21,6 +25,7 @@ export interface AgentState { export interface PersistedAgent { id: number; + agentType: AgentType; terminalName: string; jsonlFile: string; projectDir: string; diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index b6bfa642..b86ebf4b 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -57,7 +57,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1432,7 +1431,6 @@ "integrity": "sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1443,7 +1441,6 @@ "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1503,7 +1500,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1755,7 +1751,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1861,7 +1856,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2083,7 +2077,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2770,7 +2763,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2832,7 +2824,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3038,7 +3029,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3125,7 +3115,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3247,7 +3236,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 5d1c6ded..08c28698 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -225,7 +225,7 @@ function App() { void + onOpenAgent: (agentType: string) => void onToggleEditMode: () => void isDebugMode: boolean onToggleDebugMode: () => void @@ -46,7 +59,7 @@ const btnActive: React.CSSProperties = { export function BottomToolbar({ isEditMode, - onOpenClaude, + onOpenAgent, onToggleEditMode, isDebugMode, onToggleDebugMode, @@ -54,40 +67,67 @@ export function BottomToolbar({ }: BottomToolbarProps) { const [hovered, setHovered] = useState(null) const [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [isAgentPickerOpen, setIsAgentPickerOpen] = useState(false) const [isFolderPickerOpen, setIsFolderPickerOpen] = useState(false) const [hoveredFolder, setHoveredFolder] = useState(null) + const [hoveredAgentType, setHoveredAgentType] = useState(null) + const [pendingAgentType, setPendingAgentType] = useState(null) const folderPickerRef = useRef(null) + const agentPickerRef = useRef(null) - // Close folder picker on outside click + // Close pickers on outside click useEffect(() => { - if (!isFolderPickerOpen) return + if (!isFolderPickerOpen && !isAgentPickerOpen) return const handleClick = (e: MouseEvent) => { - if (folderPickerRef.current && !folderPickerRef.current.contains(e.target as Node)) { + if (isFolderPickerOpen && folderPickerRef.current && !folderPickerRef.current.contains(e.target as Node)) { setIsFolderPickerOpen(false) + setPendingAgentType(null) + } + if (isAgentPickerOpen && agentPickerRef.current && !agentPickerRef.current.contains(e.target as Node)) { + setIsAgentPickerOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) - }, [isFolderPickerOpen]) + }, [isFolderPickerOpen, isAgentPickerOpen]) const hasMultipleFolders = workspaceFolders.length > 1 const handleAgentClick = () => { - if (hasMultipleFolders) { - setIsFolderPickerOpen((v) => !v) + setIsAgentPickerOpen((v) => !v) + setIsFolderPickerOpen(false) + setPendingAgentType(null) + } + + const handleAgentTypeSelect = (agentType: string) => { + if (agentType === 'adopt-terminal') { + // Adopt existing terminal — no folder selection needed + setIsAgentPickerOpen(false) + vscode.postMessage({ type: 'adoptTerminal' }) + return + } + + if (hasMultipleFolders && agentType !== 'vscode-terminal') { + // Show folder picker for non-terminal agents in multi-root workspace + setPendingAgentType(agentType) + setIsAgentPickerOpen(false) + setIsFolderPickerOpen(true) } else { - onOpenClaude() + setIsAgentPickerOpen(false) + onOpenAgent(agentType) } } const handleFolderSelect = (folder: WorkspaceFolder) => { setIsFolderPickerOpen(false) - vscode.postMessage({ type: 'openClaude', folderPath: folder.path }) + const agentType = pendingAgentType || 'claude-code' + setPendingAgentType(null) + vscode.postMessage({ type: 'openClaude', agentType, folderPath: folder.path }) } return (
-
+
- {isFolderPickerOpen && ( + {isAgentPickerOpen && (
- {workspaceFolders.map((folder, i) => ( +
+ Choose agent type +
+ {AGENT_TYPES.map((at, i) => ( ))}
)}
+ {/* Folder picker for multi-root workspaces */} + {isFolderPickerOpen && ( +
+
+ Choose workspace folder +
+ {workspaceFolders.map((folder, i) => ( + + ))} +
+ )}