diff --git a/.gitignore b/.gitignore index 827fdbf..a0a6e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ +.DS_Store node_modules *.tsbuildinfo .env .env.* *.log .gstack/ +dist/ +.vscode/ +vscode-extension/out/ +*.vsix diff --git a/README.md b/README.md index 32e0ffb..5555b38 100644 --- a/README.md +++ b/README.md @@ -283,11 +283,36 @@ Typical usage: **$5-20/month** for active development. Start with free models | `/help` | List all commands | | `/exit` | Quit | +## VS Code Extension + +RunCode is also available as a VS Code sidebar extension. + +### Install from VSIX + +```bash +cd vscode-extension +npm install && npm run compile +npx @vscode/vsce package +code --install-extension runcode-vscode-0.1.0.vsix +``` + +Or in VS Code: Extensions panel → `...` → **Install from VSIX...** + +### Install from Source (Development) + +1. Open the repo in VS Code +2. Run `npm install && npm run build` in the root +3. Run `npm install && npm run compile` in `vscode-extension/` +4. Press `F5` to launch the Extension Development Host + +The extension appears as a **RunCode** panel in the sidebar with the same agent capabilities as the CLI: model switching, live balance tracking, tool execution, and all slash commands. + ## Architecture ``` src/ ├── agent/ # Core agent loop, LLM client, token optimization +├── api/ # Headless session API (VS Code host) ├── tools/ # 10 built-in tools (read, write, edit, bash, ...) ├── ui/ # Terminal UI + model picker ├── proxy/ # Payment proxy for Claude Code @@ -297,6 +322,11 @@ src/ ├── stats/ # Usage tracking ├── config.ts # Global configuration └── index.ts # Entry point +vscode-extension/ +├── src/extension.ts # Webview provider + live balance tracking +├── media/icon.svg # Extension icon +├── package.json # VS Code extension manifest +└── tsconfig.json ``` ## Development diff --git a/dist/agent/commands.js b/dist/agent/commands.js index 2b00d09..68b7ab7 100644 --- a/dist/agent/commands.js +++ b/dist/agent/commands.js @@ -13,7 +13,7 @@ import { BLOCKRUN_DIR, VERSION } from '../config.js'; import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js'; import { forceCompact } from './compact.js'; import { getStatsSummary } from '../stats/tracker.js'; -import { resolveModel } from '../ui/model-picker.js'; +import { formatModelPickerListText, listPickerModelsFlat, resolveModel } from '../ui/model-picker.js'; import { listSessions, loadSessionHistory, } from '../session/storage.js'; // ─── Git helpers ────────────────────────────────────────────────────────── function gitExec(cmd, cwd, timeout = 5000, maxBuffer) { @@ -145,6 +145,49 @@ const DIRECT_COMMANDS = { }); emitDone(ctx); }, + '/history': (ctx) => { + const { history, config } = ctx; + const modelName = config.model.split('/').pop() || config.model; + let output = '**Conversation History**\n\n'; + if (history.length === 0) { + output += 'No history in the current session yet.\n'; + } + else { + for (let i = 0; i < history.length; i++) { + const turn = history[i]; + const rolePrefix = turn.role === 'user' ? '[user]' : `[${modelName}]`; + const numPrefix = `[${i + 1}]`; + let turnText = ''; + if (typeof turn.content === 'string') { + turnText = turn.content; + } + else if (Array.isArray(turn.content)) { + const textParts = turn.content + .filter(p => p.type === 'text' && p.text.trim()) + .map(p => p.text.trim()); + if (textParts.length > 0) { + turnText = textParts.join(' '); + } + else { + const toolCall = turn.content.find(p => p.type === 'tool_use'); + if (toolCall) { + turnText = `(Thinking and using tool: ${toolCall.name})`; + } + const toolResult = turn.content.find(p => p.type === 'tool_result'); + if (toolResult) { + turnText = `(Processing tool result)`; + } + } + } + if (turnText.trim()) { + output += `${numPrefix} ${rolePrefix} ${turnText.trim()}\n\n`; + } + } + } + output += '\nUse `/delete ` to remove turns (e.g., `/delete 2` or `/delete 3-5`).\n'; + ctx.onEvent({ kind: 'text_delta', text: output }); + emitDone(ctx); + }, '/bug': (ctx) => { ctx.onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' }); emitDone(ctx); @@ -407,17 +450,31 @@ export async function handleSlashCommand(input, ctx) { await DIRECT_COMMANDS[input](ctx); return { handled: true }; } - // /model — show current model or switch with /model - if (input === '/model' || input.startsWith('/model ')) { - if (input === '/model') { - ctx.onEvent({ kind: 'text_delta', text: `Current model: **${ctx.config.model}**\n` + - `Switch with: \`/model \` (e.g. \`/model sonnet\`, \`/model free\`, \`/model gemini\`)\n` + // /model — show current model + full list (via onEvent, not stderr) or switch with /model + if (input === '/model' || input === '/models' || input.startsWith('/model ')) { + if (input === '/model' || input === '/models') { + const listText = formatModelPickerListText(ctx.config.model); + ctx.onEvent({ + kind: 'text_delta', + text: `**Select a model**\n\n` + + `Current: **${ctx.config.model}**\n\n` + + `${listText}\n`, }); } else { - const newModel = resolveModel(input.slice(7).trim()); + const arg = input.slice(7).trim(); + const flat = listPickerModelsFlat(); + const num = parseInt(arg, 10); + let newModel; + if (!isNaN(num) && num >= 1 && num <= flat.length) { + newModel = flat[num - 1].id; + } + else { + newModel = resolveModel(arg); + } ctx.config.model = newModel; ctx.onEvent({ kind: 'text_delta', text: `Model → **${newModel}**\n` }); + ctx.onEvent({ kind: 'status_update', model: newModel }); } emitDone(ctx); return { handled: true }; @@ -439,6 +496,57 @@ export async function handleSlashCommand(input, ctx) { emitDone(ctx); return { handled: true }; } + // /delete <...> + if (input.startsWith('/delete ')) { + const arg = input.slice('/delete '.length).trim(); + if (!arg) { + ctx.onEvent({ kind: 'text_delta', text: 'Usage: /delete (e.g., /delete 3, /delete 2,5, /delete 4-7)\n' }); + emitDone(ctx); + return { handled: true }; + } + const indicesToDelete = new Set(); + const parts = arg.split(',').map(p => p.trim()); + for (const part of parts) { + if (part.includes('-')) { + const [start, end] = part.split('-').map(n => parseInt(n, 10)); + if (!isNaN(start) && !isNaN(end) && start <= end) { + for (let i = start; i <= end; i++) { + indicesToDelete.add(i - 1); // User sees 1-based, we use 0-based + } + } + } + else { + const index = parseInt(part, 10); + if (!isNaN(index)) { + indicesToDelete.add(index - 1); // 0-based + } + } + } + if (indicesToDelete.size === 0) { + ctx.onEvent({ kind: 'text_delta', text: 'No valid turn numbers provided.\n' }); + emitDone(ctx); + return { handled: true }; + } + const sortedIndices = Array.from(indicesToDelete).sort((a, b) => b - a); // Sort descending + let deletedCount = 0; + const deletedNumbers = []; + for (const index of sortedIndices) { + if (index >= 0 && index < ctx.history.length) { + ctx.history.splice(index, 1); + deletedCount++; + deletedNumbers.push(index + 1); + } + } + if (deletedCount > 0) { + resetTokenAnchor(); + ctx.onEvent({ kind: 'text_delta', text: `Deleted turn(s) ${deletedNumbers.reverse().join(', ')} from history.\n` }); + } + else { + ctx.onEvent({ kind: 'text_delta', text: `No matching turns found to delete.\n` }); + } + emitDone(ctx); + return { handled: true }; + } // /resume if (input.startsWith('/resume ')) { const targetId = input.slice(8).trim(); diff --git a/dist/agent/types.d.ts b/dist/agent/types.d.ts index 9025ff7..8550091 100644 --- a/dist/agent/types.d.ts +++ b/dist/agent/types.d.ts @@ -98,7 +98,12 @@ export interface StreamUsageInfo { model: string; calls: number; } -export type StreamEvent = StreamTextDelta | StreamThinkingDelta | StreamCapabilityStart | StreamCapabilityInputDelta | StreamCapabilityProgress | StreamCapabilityDone | StreamTurnDone | StreamUsageInfo; +/** UI hosts (e.g. VS Code) — update persistent model display without stderr */ +export interface StreamStatusUpdate { + kind: 'status_update'; + model: string; +} +export type StreamEvent = StreamTextDelta | StreamThinkingDelta | StreamCapabilityStart | StreamCapabilityInputDelta | StreamCapabilityProgress | StreamCapabilityDone | StreamTurnDone | StreamUsageInfo | StreamStatusUpdate; export interface AgentConfig { model: string; apiUrl: string; diff --git a/dist/banner.d.ts b/dist/banner.d.ts index d6b7f13..ecaa11d 100644 --- a/dist/banner.d.ts +++ b/dist/banner.d.ts @@ -1 +1,4 @@ +/** Plain-text banner lines (Run + Code ASCII side by side) for non-terminal UIs (e.g. VS Code webview). */ +export declare function getBannerPlainLines(): string[]; +export declare function getBannerFooterLines(version: string): string[]; export declare function printBanner(version: string): void; diff --git a/dist/banner.js b/dist/banner.js index 2b002fe..3c43d2e 100644 --- a/dist/banner.js +++ b/dist/banner.js @@ -16,6 +16,20 @@ const CODE_ART = [ '╚██████╗╚██████╔╝██████╔╝███████╗', ' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝', ]; +/** Plain-text banner lines (Run + Code ASCII side by side) for non-terminal UIs (e.g. VS Code webview). */ +export function getBannerPlainLines() { + const lines = []; + for (let i = 0; i < RUN_ART.length; i++) { + lines.push(RUN_ART[i] + CODE_ART[i]); + } + return lines; +} +export function getBannerFooterLines(version) { + return [ + ' RunCode · AI Coding Agent · blockrun.ai · v' + version, + ' 41+ models · Pay per use with USDC · /help for commands', + ]; +} export function printBanner(version) { const runColor = chalk.hex('#FFD700'); // Gold for "Run" const codeColor = chalk.cyan; // Cyan for "Code" diff --git a/dist/commands/config.js b/dist/commands/config.js index 23bd18c..38df4de 100644 --- a/dist/commands/config.js +++ b/dist/commands/config.js @@ -33,7 +33,6 @@ function saveConfig(config) { } catch (err) { console.error(chalk.red(`Failed to save config: ${err.message}`)); - process.exit(1); } } function isValidKey(key) { diff --git a/dist/commands/history.d.ts b/dist/commands/history.d.ts new file mode 100644 index 0000000..edabfcb --- /dev/null +++ b/dist/commands/history.d.ts @@ -0,0 +1,5 @@ +interface HistoryOptions { + n?: string; +} +export declare function historyCommand(options: HistoryOptions): void; +export {}; diff --git a/dist/commands/history.js b/dist/commands/history.js new file mode 100644 index 0000000..54af133 --- /dev/null +++ b/dist/commands/history.js @@ -0,0 +1,31 @@ +import chalk from 'chalk'; +import { loadStats } from '../stats/tracker.js'; +export function historyCommand(options) { + const { history } = loadStats(); + const limit = Math.min(parseInt(options.n || '20', 10), history.length); + console.log(chalk.bold(` +📜 Last ${limit} Requests\n`)); + console.log('─'.repeat(55)); + if (history.length === 0) { + console.log(chalk.gray('\n No history recorded yet.\n')); + console.log('─'.repeat(55) + '\n'); + return; + } + const recent = history.slice(-limit).reverse(); + for (const record of recent) { + const time = new Date(record.timestamp).toLocaleString(); + const model = record.model.split('/').pop() || record.model; + const cost = '$' + record.costUsd.toFixed(5); + const tokens = `${record.inputTokens}+${record.outputTokens}`.padEnd(10); + const latency = `${record.latencyMs}ms`.padEnd(8); + const fallbackMark = record.fallback ? chalk.yellow(' ↺') : ''; + console.log(chalk.gray(`[${time}]`) + + ` ${model.padEnd(20)}${fallbackMark} ` + + chalk.cyan(tokens) + + chalk.magenta(latency) + + chalk.green(cost)); + } + console.log('\n' + '─'.repeat(55)); + console.log(chalk.gray(` Showing ${limit} of ${history.length} total records.`)); + console.log(chalk.gray(' Run `runcode stats` for more detailed statistics.\n')); +} diff --git a/dist/ui/app.js b/dist/ui/app.js index 98a215c..77951ca 100644 --- a/dist/ui/app.js +++ b/dist/ui/app.js @@ -389,6 +389,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain }); break; } + case 'status_update': { + setCurrentModel(event.model); + break; + } case 'usage': { setCurrentModel(event.model); setTurnTokens(prev => ({ diff --git a/dist/ui/model-picker.d.ts b/dist/ui/model-picker.d.ts index ea42657..c6a2f86 100644 --- a/dist/ui/model-picker.d.ts +++ b/dist/ui/model-picker.d.ts @@ -7,8 +7,22 @@ export declare const MODEL_SHORTCUTS: Record; * Resolve a model name — supports shortcuts. */ export declare function resolveModel(input: string): string; +interface ModelEntry { + id: string; + shortcut: string; + label: string; + price: string; +} +/** Flat curated list in picker order (for numbering / `/model 3`, etc.). */ +export declare function listPickerModelsFlat(): ModelEntry[]; +/** + * Plain-text model list (same layout as interactive pickModel), for non-TTY hosts + * (e.g. VS Code webview) that only receive StreamEvents — not console.error. + */ +export declare function formatModelPickerListText(currentModel?: string): string; /** * Show interactive model picker. Returns the selected model ID. * Falls back to text input if terminal doesn't support raw mode. */ export declare function pickModel(currentModel?: string): Promise; +export {}; diff --git a/dist/ui/model-picker.js b/dist/ui/model-picker.js index 73ab044..10b4446 100644 --- a/dist/ui/model-picker.js +++ b/dist/ui/model-picker.js @@ -103,16 +103,40 @@ const PICKER_MODELS = [ ], }, ]; +/** Flat curated list in picker order (for numbering / `/model 3`, etc.). */ +export function listPickerModelsFlat() { + const out = []; + for (const cat of PICKER_MODELS) { + out.push(...cat.models); + } + return out; +} +/** + * Plain-text model list (same layout as interactive pickModel), for non-TTY hosts + * (e.g. VS Code webview) that only receive StreamEvents — not console.error. + */ +export function formatModelPickerListText(currentModel) { + const lines = []; + let idx = 1; + for (const cat of PICKER_MODELS) { + lines.push(`── ${cat.category} ──`); + for (const m of cat.models) { + const cur = m.id === currentModel ? ' <- current' : ''; + lines.push(` ${String(idx).padStart(2)}. ${m.label.padEnd(26)} ${m.shortcut.padEnd(14)} ${m.price}${cur}`); + idx++; + } + lines.push(''); + } + lines.push('Enter a number, shortcut, or use `/model ` (e.g. `/model 1`, `/model sonnet`, `/model free`).'); + return lines.join('\n'); +} /** * Show interactive model picker. Returns the selected model ID. * Falls back to text input if terminal doesn't support raw mode. */ export async function pickModel(currentModel) { // Flatten for numbering - const allModels = []; - for (const cat of PICKER_MODELS) { - allModels.push(...cat.models); - } + const allModels = listPickerModelsFlat(); // Display console.error(''); console.error(chalk.bold(' Select a model:\n')); diff --git a/dist/ui/terminal.js b/dist/ui/terminal.js index dac46f1..adab3d2 100644 --- a/dist/ui/terminal.js +++ b/dist/ui/terminal.js @@ -274,6 +274,8 @@ export class TerminalUI { if (event.model) this.sessionModel = event.model; break; + case 'status_update': + break; case 'turn_done': { this.spinner.stop(); // Flush any remaining markdown diff --git a/package-lock.json b/package-lock.json index 6c4dca2..aae3e21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/runcode", - "version": "2.5.28", + "version": "2.5.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/runcode", - "version": "2.5.28", + "version": "2.5.29", "license": "Apache-2.0", "dependencies": { "@blockrun/llm": "^1.4.2", @@ -2992,21 +2992,6 @@ "node": ">= 0.8" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index bde5b4d..1b800b7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "2.5.29", "description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.", "type": "module", + "exports": { + ".": "./dist/index.js", + "./vscode-session": "./dist/api/vscode-session.js" + }, "bin": { "runcode": "./dist/index.js" }, diff --git a/src/agent/commands.ts b/src/agent/commands.ts index ade7667..8a7b238 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -14,7 +14,7 @@ import { BLOCKRUN_DIR, VERSION } from '../config.js'; import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js'; import { forceCompact } from './compact.js'; import { getStatsSummary } from '../stats/tracker.js'; -import { resolveModel } from '../ui/model-picker.js'; +import { formatModelPickerListText, listPickerModelsFlat, resolveModel } from '../ui/model-picker.js'; import type { ModelClient } from './llm.js'; import type { AgentConfig, Dialogue, StreamEvent } from './types.js'; import { @@ -159,6 +159,50 @@ const DIRECT_COMMANDS: Record Promise | v }); emitDone(ctx); }, + '/history': (ctx) => { + const { history, config } = ctx; + const modelName = config.model.split('/').pop() || config.model; + let output = '**Conversation History**\n\n'; + + if (history.length === 0) { + output += 'No history in the current session yet.\n'; + } else { + for (let i = 0; i < history.length; i++) { + const turn = history[i]; + const rolePrefix = turn.role === 'user' ? '[user]' : `[${modelName}]`; + const numPrefix = `[${i + 1}]`; + let turnText = ''; + + if (typeof turn.content === 'string') { + turnText = turn.content; + } else if (Array.isArray(turn.content)) { + const textParts = turn.content + .filter(p => p.type === 'text' && p.text.trim()) + .map(p => (p as { text: string }).text.trim()); + + if (textParts.length > 0) { + turnText = textParts.join(' '); + } else { + const toolCall = turn.content.find(p => p.type === 'tool_use'); + if (toolCall) { + turnText = `(Thinking and using tool: ${toolCall.name})`; + } + const toolResult = turn.content.find(p => p.type === 'tool_result'); + if (toolResult) { + turnText = `(Processing tool result)`; + } + } + } + + if (turnText.trim()) { + output += `${numPrefix} ${rolePrefix} ${turnText.trim()}\n\n`; + } + } + } + output += '\nUse `/delete ` to remove turns (e.g., `/delete 2` or `/delete 3-5`).\n'; + ctx.onEvent({ kind: 'text_delta', text: output }); + emitDone(ctx); + }, '/bug': (ctx) => { ctx.onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' }); emitDone(ctx); @@ -414,17 +458,30 @@ export async function handleSlashCommand( return { handled: true }; } - // /model — show current model or switch with /model - if (input === '/model' || input.startsWith('/model ')) { - if (input === '/model') { - ctx.onEvent({ kind: 'text_delta', text: - `Current model: **${ctx.config.model}**\n` + - `Switch with: \`/model \` (e.g. \`/model sonnet\`, \`/model free\`, \`/model gemini\`)\n` + // /model — show current model + full list (via onEvent, not stderr) or switch with /model + if (input === '/model' || input === '/models' || input.startsWith('/model ')) { + if (input === '/model' || input === '/models') { + const listText = formatModelPickerListText(ctx.config.model); + ctx.onEvent({ + kind: 'text_delta', + text: + `**Select a model**\n\n` + + `Current: **${ctx.config.model}**\n\n` + + `${listText}\n`, }); } else { - const newModel = resolveModel(input.slice(7).trim()); + const arg = input.slice(7).trim(); + const flat = listPickerModelsFlat(); + const num = parseInt(arg, 10); + let newModel: string; + if (!isNaN(num) && num >= 1 && num <= flat.length) { + newModel = flat[num - 1]!.id; + } else { + newModel = resolveModel(arg); + } ctx.config.model = newModel; ctx.onEvent({ kind: 'text_delta', text: `Model → **${newModel}**\n` }); + ctx.onEvent({ kind: 'status_update', model: newModel }); } emitDone(ctx); return { handled: true }; @@ -445,6 +502,63 @@ export async function handleSlashCommand( return { handled: true }; } + // /delete <...> + if (input.startsWith('/delete ')) { + const arg = input.slice('/delete '.length).trim(); + if (!arg) { + ctx.onEvent({ kind: 'text_delta', text: 'Usage: /delete (e.g., /delete 3, /delete 2,5, /delete 4-7)\n' }); + emitDone(ctx); + return { handled: true }; + } + + const indicesToDelete = new Set(); + const parts = arg.split(',').map(p => p.trim()); + + for (const part of parts) { + if (part.includes('-')) { + const [start, end] = part.split('-').map(n => parseInt(n, 10)); + if (!isNaN(start) && !isNaN(end) && start <= end) { + for (let i = start; i <= end; i++) { + indicesToDelete.add(i - 1); // User sees 1-based, we use 0-based + } + } + } else { + const index = parseInt(part, 10); + if (!isNaN(index)) { + indicesToDelete.add(index - 1); // 0-based + } + } + } + + if (indicesToDelete.size === 0) { + ctx.onEvent({ kind: 'text_delta', text: 'No valid turn numbers provided.\n' }); + emitDone(ctx); + return { handled: true }; + } + + const sortedIndices = Array.from(indicesToDelete).sort((a, b) => b - a); // Sort descending + let deletedCount = 0; + const deletedNumbers: number[] = []; + + for (const index of sortedIndices) { + if (index >= 0 && index < ctx.history.length) { + ctx.history.splice(index, 1); + deletedCount++; + deletedNumbers.push(index + 1); + } + } + + if (deletedCount > 0) { + resetTokenAnchor(); + ctx.onEvent({ kind: 'text_delta', text: `Deleted turn(s) ${deletedNumbers.reverse().join(', ')} from history.\n` }); + } else { + ctx.onEvent({ kind: 'text_delta', text: `No matching turns found to delete.\n` }); + } + + emitDone(ctx); + return { handled: true }; + } + // /resume if (input.startsWith('/resume ')) { const targetId = input.slice(8).trim(); diff --git a/src/agent/types.ts b/src/agent/types.ts index b67619f..5a07171 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -127,6 +127,12 @@ export interface StreamUsageInfo { calls: number; } +/** UI hosts (e.g. VS Code) — update persistent model display without stderr */ +export interface StreamStatusUpdate { + kind: 'status_update'; + model: string; +} + export type StreamEvent = | StreamTextDelta | StreamThinkingDelta @@ -135,7 +141,8 @@ export type StreamEvent = | StreamCapabilityProgress | StreamCapabilityDone | StreamTurnDone - | StreamUsageInfo; + | StreamUsageInfo + | StreamStatusUpdate; // ─── Agent Configuration ─────────────────────────────────────────────────── diff --git a/src/api/vscode-session.ts b/src/api/vscode-session.ts new file mode 100644 index 0000000..414302b --- /dev/null +++ b/src/api/vscode-session.ts @@ -0,0 +1,169 @@ +/** + * Headless agent session for VS Code (or any host that supplies getUserInput + onEvent). + */ + +import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm'; +import { loadChain, API_URLS, VERSION } from '../config.js'; +import { getBannerFooterLines, getBannerPlainLines } from '../banner.js'; +import { flushStats } from '../stats/tracker.js'; +import { loadConfig } from '../commands/config.js'; +import { estimateCost } from '../pricing.js'; +import { assembleInstructions } from '../agent/context.js'; +import { interactiveSession } from '../agent/loop.js'; +import { allCapabilities, createSubAgentCapability } from '../tools/index.js'; +import { resolveModel } from '../ui/model-picker.js'; +import { loadMcpConfig } from '../mcp/config.js'; +import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js'; +import type { AgentConfig, StreamEvent } from '../agent/types.js'; + +export type { StreamEvent } from '../agent/types.js'; +export { estimateCost } from '../pricing.js'; + +/** Welcome panel: same branding as CLI, plus live wallet / model / workspace. */ +export interface VsCodeWelcomeInfo { + bannerLines: string[]; + footerLines: string[]; + model: string; + chain: 'base' | 'solana'; + walletAddress: string; + balance: string; + workDir: string; +} + +function resolveEffectiveModel(explicit?: string): string { + const config = loadConfig(); + const configModel = config['default-model']; + if (explicit) { + return resolveModel(explicit); + } + if (configModel) { + return configModel; + } + const promoExpiry = new Date('2026-04-15'); + return Date.now() < promoExpiry.getTime() ? 'zai/glm-5' : 'google/gemini-2.5-flash'; +} + +/** On-chain wallet + balance only (no model). Session model can differ from config — use for live status bar refresh. */ +export async function getVsCodeWalletStatus(_workDir: string): Promise<{ + chain: 'base' | 'solana'; + walletAddress: string; + balance: string; +}> { + const chain = loadChain(); + + let walletAddress = ''; + if (chain === 'solana') { + const w = await getOrCreateSolanaWallet(); + walletAddress = w.address; + } else { + const w = getOrCreateWallet(); + walletAddress = w.address; + } + + let balance = 'checking…'; + try { + if (chain === 'solana') { + const { setupAgentSolanaWallet } = await import('@blockrun/llm'); + const client = await setupAgentSolanaWallet({ silent: true }); + balance = `$${(await client.getBalance()).toFixed(2)} USDC`; + } else { + const { setupAgentWallet } = await import('@blockrun/llm'); + const client = setupAgentWallet({ silent: true }); + balance = `$${(await client.getBalance()).toFixed(2)} USDC`; + } + } catch { + balance = 'unknown'; + } + + return { chain, walletAddress, balance }; +} + +/** Load wallet, balance, and resolved model for the welcome UI (no agent loop). */ +export async function getVsCodeWelcomeInfo(workDir: string): Promise { + const model = resolveEffectiveModel(); + const { chain, walletAddress, balance } = await getVsCodeWalletStatus(workDir); + + return { + bannerLines: getBannerPlainLines(), + footerLines: getBannerFooterLines(VERSION), + model, + chain, + walletAddress, + balance, + workDir, + }; +} + +export interface VsCodeSessionOptions { + /** Workspace root — tools run here */ + workDir: string; + model?: string; + debug?: boolean; + /** + * When true (default), tools run without interactive permission prompts (recommended in VS Code). + */ + trust?: boolean; + onEvent: (event: StreamEvent) => void; + getUserInput: () => Promise; + onAbortReady?: (abort: () => void) => void; + permissionPromptFn?: AgentConfig['permissionPromptFn']; + onAskUser?: AgentConfig['onAskUser']; +} + +export async function runVsCodeSession(options: VsCodeSessionOptions): Promise { + const chain = loadChain(); + const apiUrl = API_URLS[chain]; + + const model = resolveEffectiveModel(options.model); + + if (chain === 'solana') { + await getOrCreateSolanaWallet(); + } else { + getOrCreateWallet(); + } + + const systemInstructions = assembleInstructions(options.workDir); + + const mcpConfig = loadMcpConfig(options.workDir); + let mcpTools: typeof allCapabilities = []; + const mcpServerCount = Object.keys(mcpConfig.mcpServers).filter( + (k) => !mcpConfig.mcpServers[k].disabled + ).length; + if (mcpServerCount > 0) { + try { + mcpTools = await connectMcpServers(mcpConfig, options.debug); + } catch { + /* non-fatal */ + } + } + + const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities); + const capabilities = [...allCapabilities, ...mcpTools, subAgent]; + + const trust = options.trust !== false; + const agentConfig: AgentConfig = { + model, + apiUrl, + chain, + systemInstructions, + capabilities, + maxTurns: 100, + workingDir: options.workDir, + permissionMode: trust ? 'trust' : 'default', + debug: options.debug, + permissionPromptFn: options.permissionPromptFn, + onAskUser: options.onAskUser, + }; + + try { + await interactiveSession( + agentConfig, + options.getUserInput, + options.onEvent, + options.onAbortReady + ); + } finally { + flushStats(); + await disconnectMcpServers(); + } +} diff --git a/src/banner.ts b/src/banner.ts index 743d15d..c9cf9c0 100644 --- a/src/banner.ts +++ b/src/banner.ts @@ -19,6 +19,22 @@ const CODE_ART = [ ' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝', ]; +/** Plain-text banner lines (Run + Code ASCII side by side) for non-terminal UIs (e.g. VS Code webview). */ +export function getBannerPlainLines(): string[] { + const lines: string[] = []; + for (let i = 0; i < RUN_ART.length; i++) { + lines.push(RUN_ART[i] + CODE_ART[i]); + } + return lines; +} + +export function getBannerFooterLines(version: string): string[] { + return [ + ' RunCode · AI Coding Agent · blockrun.ai · v' + version, + ' 41+ models · Pay per use with USDC · /help for commands', + ]; +} + export function printBanner(version: string) { const runColor = chalk.hex('#FFD700'); // Gold for "Run" const codeColor = chalk.cyan; // Cyan for "Code" diff --git a/src/commands/config.ts b/src/commands/config.ts index dc0e046..c0425f1 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -50,7 +50,6 @@ function saveConfig(config: AppConfig): void { }); } catch (err) { console.error(chalk.red(`Failed to save config: ${(err as Error).message}`)); - process.exit(1); } } diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 43c2b09..5950eb7 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -482,6 +482,10 @@ function RunCodeApp({ }); break; } + case 'status_update': { + setCurrentModel(event.model); + break; + } case 'usage': { setCurrentModel(event.model); setTurnTokens(prev => ({ diff --git a/src/ui/model-picker.ts b/src/ui/model-picker.ts index ac121de..b6fb9db 100644 --- a/src/ui/model-picker.ts +++ b/src/ui/model-picker.ts @@ -118,16 +118,44 @@ const PICKER_MODELS: { category: string; models: ModelEntry[] }[] = [ }, ]; +/** Flat curated list in picker order (for numbering / `/model 3`, etc.). */ +export function listPickerModelsFlat(): ModelEntry[] { + const out: ModelEntry[] = []; + for (const cat of PICKER_MODELS) { + out.push(...cat.models); + } + return out; +} + +/** + * Plain-text model list (same layout as interactive pickModel), for non-TTY hosts + * (e.g. VS Code webview) that only receive StreamEvents — not console.error. + */ +export function formatModelPickerListText(currentModel?: string): string { + const lines: string[] = []; + let idx = 1; + for (const cat of PICKER_MODELS) { + lines.push(`── ${cat.category} ──`); + for (const m of cat.models) { + const cur = m.id === currentModel ? ' <- current' : ''; + lines.push( + ` ${String(idx).padStart(2)}. ${m.label.padEnd(26)} ${m.shortcut.padEnd(14)} ${m.price}${cur}` + ); + idx++; + } + lines.push(''); + } + lines.push('Enter a number, shortcut, or use `/model ` (e.g. `/model 1`, `/model sonnet`, `/model free`).'); + return lines.join('\n'); +} + /** * Show interactive model picker. Returns the selected model ID. * Falls back to text input if terminal doesn't support raw mode. */ export async function pickModel(currentModel?: string): Promise { // Flatten for numbering - const allModels: ModelEntry[] = []; - for (const cat of PICKER_MODELS) { - allModels.push(...cat.models); - } + const allModels = listPickerModelsFlat(); // Display console.error(''); diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 1622633..b84ca95 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -304,6 +304,9 @@ export class TerminalUI { if (event.model) this.sessionModel = event.model; break; + case 'status_update': + break; + case 'turn_done': { this.spinner.stop(); // Flush any remaining markdown diff --git a/vscode-extension/media/icon.svg b/vscode-extension/media/icon.svg new file mode 100644 index 0000000..04a502c --- /dev/null +++ b/vscode-extension/media/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json new file mode 100644 index 0000000..acf9e51 --- /dev/null +++ b/vscode-extension/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "runcode-vscode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "runcode-vscode", + "version": "0.1.0", + "dependencies": { + "@blockrun/runcode": "file:.." + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.7.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "..": { + "name": "@blockrun/runcode", + "version": "2.5.29", + "license": "Apache-2.0", + "dependencies": { + "@blockrun/llm": "^1.4.2", + "@modelcontextprotocol/sdk": "^1.29.0", + "@solana/spl-token": "^0.4.14", + "@solana/web3.js": "^1.98.4", + "@types/react": "^19.2.14", + "bs58": "^6.0.0", + "chalk": "^5.4.0", + "commander": "^13.0.0", + "ink": "^6.8.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "react": "^19.2.4" + }, + "bin": { + "runcode": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@blockrun/runcode": { + "resolved": "..", + "link": true + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.115.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.115.0.tgz", + "integrity": "sha512-/M8cdznOlqtMqduHKKlIF00v4eum4ZWKgn8YoPRKcN6PDdvoWeeqDaQSnw63ipDbq1Uzz78Wndk/d0uSPwORfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..6b0dd4d --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,59 @@ +{ + "name": "runcode-vscode", + "displayName": "RunCode Chat", + "description": "Side panel chat with RunCode agent (same engine as CLI)", + "version": "0.1.0", + "publisher": "runcode-local", + "private": true, + "type": "module", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onView:runcode.chatPanel" + ], + "main": "./out/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "runcode-chat", + "title": "RunCode", + "icon": "media/icon.svg" + } + ] + }, + "views": { + "runcode-chat": [ + { + "type": "webview", + "id": "runcode.chatPanel", + "name": "Chat", + "visibility": "visible" + } + ] + }, + "commands": [ + { + "command": "runcode.stopGeneration", + "title": "RunCode: Stop generation" + } + ] + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "vscode:prepublish": "npm run compile" + }, + "dependencies": { + "@blockrun/runcode": "file:.." + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.7.0" + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 0000000..4c42649 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,782 @@ +import * as vscode from 'vscode'; +import { + runVsCodeSession, + getVsCodeWelcomeInfo, + getVsCodeWalletStatus, + estimateCost, + type StreamEvent, +} from '@blockrun/runcode/vscode-session'; + +let latestAbort: (() => void) | undefined; + +export function activate(context: vscode.ExtensionContext) { + const provider = new RuncodeChatProvider(context.extensionUri); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(RuncodeChatProvider.viewType, provider, { + webviewOptions: { retainContextWhenHidden: true }, + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('runcode.stopGeneration', () => { + latestAbort?.(); + }) + ); +} + +export function deactivate() { + latestAbort = undefined; +} + +class RuncodeChatProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'runcode.chatPanel'; + + private view?: vscode.WebviewView; + private resolveInput?: (value: string | null) => void; + private agentRunning = false; + private walletRefreshTimer: ReturnType | undefined; + + constructor(private readonly extensionUri: vscode.Uri) {} + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.view = webviewView; + const { webview } = webviewView; + + webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webview.html = getWebviewHtml(); + + webview.onDidReceiveMessage((msg) => { + void this.handleMessage(msg); + }); + + webviewView.onDidDispose(() => { + if (this.walletRefreshTimer) { + clearTimeout(this.walletRefreshTimer); + this.walletRefreshTimer = undefined; + } + this.finishInput(null); + }); + + void this.pushWelcome(); + void this.runAgentSession(); + } + + private async pushWelcome() { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!folder) { + void this.view?.webview.postMessage({ type: 'welcome', info: null }); + return; + } + try { + const info = await getVsCodeWelcomeInfo(folder); + void this.view?.webview.postMessage({ type: 'welcome', info }); + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + void this.view?.webview.postMessage({ type: 'welcomeError', message: err }); + } + } + + private finishInput(value: string | null) { + const r = this.resolveInput; + this.resolveInput = undefined; + r?.(value); + } + + private postEvent(ev: StreamEvent) { + void this.view?.webview.postMessage({ type: 'event', event: ev }); + } + + /** + * Refresh balance / chain / wallet from RPC only — never sends model (session model comes from usage /status_update). + */ + private async refreshWalletStatus() { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!folder || !this.view) return; + try { + const w = await getVsCodeWalletStatus(folder); + void this.view.webview.postMessage({ + type: 'status', + partial: { + balance: w.balance, + walletAddress: w.walletAddress || '—', + chain: w.chain, + }, + }); + } catch { + /* ignore */ + } + } + + /** Debounced: each API usage deducts USDC — poll balance shortly after (batches multi-chunk turns). */ + private scheduleWalletRefresh() { + if (this.walletRefreshTimer) { + clearTimeout(this.walletRefreshTimer); + } + this.walletRefreshTimer = setTimeout(() => { + this.walletRefreshTimer = undefined; + void this.refreshWalletStatus(); + }, 400); + } + + private async handleMessage(msg: { type?: string; text?: string }) { + if (msg.type === 'send' && typeof msg.text === 'string') { + const t = msg.text.trim(); + if (!t) return; + this.finishInput(t); + return; + } + if (msg.type === 'stop') { + const hadAbort = latestAbort != null; + latestAbort?.(); + void this.view?.webview.postMessage({ type: 'stopAck', hadAbort }); + } + } + + private async runAgentSession() { + if (this.agentRunning) return; + this.agentRunning = true; + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!folder) { + void vscode.window.showWarningMessage( + 'RunCode: Open a folder as a workspace first. Tools run in that folder.' + ); + void this.view?.webview.postMessage({ + type: 'error', + message: 'No workspace folder. Use File → Open Folder, then reopen this panel.', + }); + this.agentRunning = false; + return; + } + + const getUserInput = () => + new Promise((resolve) => { + this.resolveInput = resolve; + }); + + try { + await runVsCodeSession({ + workDir: folder, + trust: true, + debug: false, + getUserInput, + onEvent: (event) => { + if (event.kind === 'usage') { + // Attach cost so webview can compute live balance synchronously + const cost = estimateCost(event.model, event.inputTokens, event.outputTokens, event.calls); + (event as unknown as Record).cost = cost; + } + this.postEvent(event); + if (event.kind === 'turn_done' && event.reason === 'completed') { + // Sync with on-chain balance at turn end + this.scheduleWalletRefresh(); + } + }, + onAbortReady: (abort) => { + latestAbort = abort; + }, + }); + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + void this.view?.webview.postMessage({ type: 'error', message: err }); + void vscode.window.showErrorMessage(`RunCode: ${err}`); + } finally { + void this.view?.webview.postMessage({ type: 'sessionEnded' }); + this.agentRunning = false; + } + } +} + +function getWebviewHtml(): string { + const csp = ["default-src 'none'", "style-src 'unsafe-inline'", "script-src 'unsafe-inline'"].join( + '; ' + ); + + return ` + + + + + + + + +
+
+ Model + · + Balance + · + Wallet + · + Chain + · + Workspace +
+ +
+
Ready — type a message and press Enter to send (/exit to end session)
+
+
+ + + +
+
+ + +`; +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 0000000..bff1205 --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "NodeNext", + "outDir": "out", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "noEmit": false + }, + "include": ["src"] +}