mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 18:55:37 -04:00
cb30a15508
- Change comments and commands in new code to use "agent-core" - Rename OAUTH_DUMMY_KEY to agent-core-oauth-dummy-key - Add AGENT_CORE_TEST_HOME env var (with OPENCODE_TEST_HOME compat) - Keep external service URLs (opencode.ai, antigravity.opencode.ai) - Keep OPENCODE_* flags for upstream compatibility Part of technical debt cleanup (Phase 7). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
/**
|
|
* CLI/TUI Surface Adapter
|
|
*
|
|
* Terminal-based interaction with streaming text output and interactive permission prompts.
|
|
* Designed for agent-core and similar terminal-based agent interfaces.
|
|
*/
|
|
|
|
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
|
|
import { stdin, stdout } from 'node:process';
|
|
|
|
import {
|
|
BaseSurface,
|
|
type Surface,
|
|
} from './surface.js';
|
|
import {
|
|
type CLISurfaceConfig,
|
|
DEFAULT_CLI_CONFIG,
|
|
DEFAULT_PERMISSION_CONFIG,
|
|
resolvePermission,
|
|
} from './config.js';
|
|
import {
|
|
DEFAULT_CAPABILITIES,
|
|
type PermissionAction,
|
|
type PermissionRequest,
|
|
type PermissionResponse,
|
|
type StreamChunk,
|
|
type SurfaceCapabilities,
|
|
type SurfaceMessage,
|
|
type SurfaceResponse,
|
|
type ToolCall,
|
|
type ToolResult,
|
|
} from './types.js';
|
|
|
|
// =============================================================================
|
|
// CLI Surface Capabilities
|
|
// =============================================================================
|
|
|
|
const CLI_CAPABILITIES: SurfaceCapabilities = {
|
|
...DEFAULT_CAPABILITIES,
|
|
streaming: true,
|
|
interactivePrompts: true,
|
|
richText: true, // Terminal can support ANSI
|
|
media: false, // Terminal can't display media inline
|
|
threading: false, // No thread concept in CLI
|
|
typingIndicators: true, // Can show spinner/progress
|
|
reactions: false,
|
|
messageEditing: false,
|
|
maxMessageLength: 0, // Unlimited
|
|
supportedMediaTypes: [], // No media support
|
|
};
|
|
|
|
// =============================================================================
|
|
// CLI Surface Implementation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* CLI/TUI surface adapter for terminal-based agent interaction.
|
|
*/
|
|
export class CLISurface extends BaseSurface implements Surface {
|
|
readonly id = 'cli';
|
|
readonly name = 'Command Line Interface';
|
|
readonly capabilities = CLI_CAPABILITIES;
|
|
|
|
private config: CLISurfaceConfig;
|
|
private readline: ReadlineInterface | null = null;
|
|
private spinnerInterval: NodeJS.Timeout | null = null;
|
|
private spinnerFrames = ['|', '/', '-', '\\'];
|
|
private spinnerIndex = 0;
|
|
private isStreaming = false;
|
|
private streamBuffer = '';
|
|
private abortController: AbortController | null = null;
|
|
|
|
constructor(config: Partial<CLISurfaceConfig> = {}) {
|
|
super();
|
|
this.config = { ...DEFAULT_CLI_CONFIG, ...config };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async connect(): Promise<void> {
|
|
this.setState('connecting');
|
|
|
|
try {
|
|
this.readline = createInterface({
|
|
input: stdin,
|
|
output: stdout,
|
|
terminal: true,
|
|
});
|
|
|
|
this.abortController = new AbortController();
|
|
|
|
// Handle Ctrl+C gracefully
|
|
this.readline.on('SIGINT', () => {
|
|
this.handleAbort();
|
|
});
|
|
|
|
// Handle line input
|
|
this.readline.on('line', (line) => {
|
|
this.handleInput(line);
|
|
});
|
|
|
|
// Handle close
|
|
this.readline.on('close', () => {
|
|
this.emit({ type: 'state_change', state: 'disconnected' });
|
|
});
|
|
|
|
this.setState('connected');
|
|
this.showPrompt();
|
|
} catch (err) {
|
|
this.setState('error', err instanceof Error ? err : new Error(String(err)));
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
this.abortController?.abort();
|
|
this.stopSpinner();
|
|
this.readline?.close();
|
|
this.readline = null;
|
|
this.setState('disconnected');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input Handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private handleInput(line: string): void {
|
|
const trimmed = line.trim();
|
|
|
|
// Emit message event
|
|
const message: SurfaceMessage = {
|
|
id: `cli-${Date.now()}`,
|
|
senderId: 'cli-user',
|
|
senderName: 'User',
|
|
body: trimmed,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.emit({ type: 'message', message });
|
|
}
|
|
|
|
private handleAbort(): void {
|
|
if (this.isStreaming) {
|
|
this.abortController?.abort();
|
|
this.abortController = new AbortController();
|
|
this.write('\n' + this.format('Aborted.', 'dim') + '\n');
|
|
this.isStreaming = false;
|
|
this.showPrompt();
|
|
} else {
|
|
// Double Ctrl+C to exit
|
|
this.write('\n(Press Ctrl+C again to exit)\n');
|
|
this.readline?.once('SIGINT', () => {
|
|
this.disconnect();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async sendResponse(response: SurfaceResponse, _threadId?: string): Promise<void> {
|
|
this.stopSpinner();
|
|
|
|
if (response.text) {
|
|
const formatted = this.formatResponse(response.text);
|
|
this.write(formatted + '\n');
|
|
}
|
|
|
|
if (response.media && response.media.length > 0) {
|
|
for (const media of response.media) {
|
|
this.write(this.format(`[Media: ${media.path}]`, 'cyan') + '\n');
|
|
}
|
|
}
|
|
|
|
if (!response.isPartial) {
|
|
this.showPrompt();
|
|
}
|
|
}
|
|
|
|
async sendStreamChunk(chunk: StreamChunk, _threadId?: string): Promise<void> {
|
|
if (chunk.type === 'text' && chunk.text) {
|
|
if (!this.isStreaming) {
|
|
this.isStreaming = true;
|
|
this.stopSpinner();
|
|
this.clearLine();
|
|
}
|
|
|
|
this.streamBuffer += chunk.text;
|
|
this.write(chunk.text);
|
|
|
|
if (chunk.isFinal) {
|
|
this.write('\n');
|
|
this.isStreaming = false;
|
|
this.streamBuffer = '';
|
|
this.showPrompt();
|
|
}
|
|
} else if (chunk.type === 'tool_start' && chunk.tool) {
|
|
this.showToolStart(chunk.tool.name, chunk.tool.input);
|
|
} else if (chunk.type === 'tool_end' && chunk.tool) {
|
|
this.showToolEnd(chunk.tool.name, chunk.tool.output, chunk.tool.error);
|
|
} else if (chunk.type === 'thinking' && chunk.text) {
|
|
this.showThinking(chunk.text);
|
|
} else if (chunk.type === 'error' && chunk.text) {
|
|
this.write(this.format(`Error: ${chunk.text}`, 'red') + '\n');
|
|
}
|
|
}
|
|
|
|
async sendTypingIndicator(_threadId?: string): Promise<void> {
|
|
this.startSpinner();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Permission Handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async requestPermission(request: PermissionRequest): Promise<PermissionResponse> {
|
|
const permissionConfig = {
|
|
...DEFAULT_PERMISSION_CONFIG,
|
|
...this.config.permissions,
|
|
};
|
|
|
|
// Resolve automatic permission
|
|
const resolved = resolvePermission(request.type, request.description, permissionConfig);
|
|
|
|
// If no confirmation needed, apply automatically
|
|
if (!resolved.requiresConfirmation) {
|
|
return {
|
|
requestId: request.id,
|
|
action: resolved.action,
|
|
};
|
|
}
|
|
|
|
// Show interactive prompt
|
|
return this.showPermissionPrompt(request, resolved.timeoutMs);
|
|
}
|
|
|
|
private async showPermissionPrompt(
|
|
request: PermissionRequest,
|
|
timeoutMs: number
|
|
): Promise<PermissionResponse> {
|
|
return new Promise((resolve) => {
|
|
const timeoutId = timeoutMs > 0
|
|
? setTimeout(() => {
|
|
this.write('\n' + this.format('(Timed out, applying default)', 'dim') + '\n');
|
|
resolve({
|
|
requestId: request.id,
|
|
action: request.defaultAction,
|
|
});
|
|
}, timeoutMs)
|
|
: null;
|
|
|
|
// Format the permission request
|
|
const typeLabel = this.formatPermissionType(request.type);
|
|
const box = this.formatBox([
|
|
`${typeLabel} Permission Request`,
|
|
'',
|
|
request.description,
|
|
'',
|
|
request.toolCall ? `Tool: ${request.toolCall.name}` : '',
|
|
'',
|
|
`[${this.config.keyBindings.accept}] Allow [${this.config.keyBindings.deny}] Deny [a] Allow for session [d] Deny for session`,
|
|
].filter(Boolean));
|
|
|
|
this.write('\n' + box + '\n');
|
|
this.write('> ');
|
|
|
|
const handler = (line: string): void => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
this.readline?.removeListener('line', handler);
|
|
|
|
const input = line.trim().toLowerCase();
|
|
let action: PermissionAction;
|
|
let remember = false;
|
|
|
|
switch (input) {
|
|
case 'y':
|
|
case 'yes':
|
|
case 'allow':
|
|
action = 'allow';
|
|
break;
|
|
case 'a':
|
|
case 'allow_session':
|
|
action = 'allow_session';
|
|
remember = true;
|
|
break;
|
|
case 'd':
|
|
case 'deny_session':
|
|
action = 'deny_session';
|
|
remember = true;
|
|
break;
|
|
case 'n':
|
|
case 'no':
|
|
case 'deny':
|
|
default:
|
|
action = 'deny';
|
|
}
|
|
|
|
resolve({ requestId: request.id, action, remember });
|
|
};
|
|
|
|
this.readline?.on('line', handler);
|
|
});
|
|
}
|
|
|
|
private formatPermissionType(type: string): string {
|
|
const labels: Record<string, string> = {
|
|
file_read: 'File Read',
|
|
file_write: 'File Write',
|
|
file_delete: 'File Delete',
|
|
execute_command: 'Command Execution',
|
|
network_request: 'Network Request',
|
|
tool_execution: 'Tool Execution',
|
|
sensitive_data: 'Sensitive Data Access',
|
|
};
|
|
return labels[type] || type;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool Notifications
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async notifyToolStart(toolCall: ToolCall): Promise<void> {
|
|
if (this.config.showToolDetails) {
|
|
this.showToolStart(toolCall.name, toolCall.input);
|
|
} else {
|
|
this.startSpinner();
|
|
}
|
|
}
|
|
|
|
async notifyToolEnd(result: ToolResult): Promise<void> {
|
|
this.stopSpinner();
|
|
if (this.config.showToolDetails) {
|
|
this.showToolEnd(
|
|
'tool', // We don't have the name here, could be tracked
|
|
result.output,
|
|
result.error
|
|
);
|
|
}
|
|
}
|
|
|
|
private showToolStart(name: string, input?: Record<string, unknown>): void {
|
|
const header = this.format(`Tool: ${name}`, 'cyan');
|
|
this.write(`${header}\n`);
|
|
|
|
if (input && this.config.showToolDetails) {
|
|
const inputStr = JSON.stringify(input, null, 2)
|
|
.split('\n')
|
|
.map(line => ' ' + line)
|
|
.join('\n');
|
|
this.write(this.format(inputStr, 'dim') + '\n');
|
|
}
|
|
}
|
|
|
|
private showToolEnd(
|
|
_name: string,
|
|
output?: unknown,
|
|
error?: string
|
|
): void {
|
|
if (error) {
|
|
this.write(this.format(` Error: ${error}`, 'red') + '\n');
|
|
} else if (output !== undefined && this.config.showToolDetails) {
|
|
const outputStr = typeof output === 'string'
|
|
? output
|
|
: JSON.stringify(output, null, 2);
|
|
const truncated = outputStr.length > 500
|
|
? outputStr.slice(0, 500) + '...'
|
|
: outputStr;
|
|
this.write(this.format(` ${truncated}`, 'dim') + '\n');
|
|
}
|
|
}
|
|
|
|
private showThinking(text: string): void {
|
|
const formatted = this.format(`Thinking: ${text}`, 'dim');
|
|
this.write(formatted + '\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UI Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private showPrompt(): void {
|
|
if (this.config.promptStyle === 'none') return;
|
|
|
|
const prompt = this.config.promptStyle === 'full'
|
|
? this.format('> ', 'green')
|
|
: '> ';
|
|
|
|
this.write(prompt);
|
|
}
|
|
|
|
private startSpinner(): void {
|
|
if (this.spinnerInterval) return;
|
|
|
|
this.spinnerInterval = setInterval(() => {
|
|
const frame = this.spinnerFrames[this.spinnerIndex];
|
|
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
|
this.write(`\r${this.format(frame, 'cyan')} `);
|
|
}, 80);
|
|
}
|
|
|
|
private stopSpinner(): void {
|
|
if (this.spinnerInterval) {
|
|
clearInterval(this.spinnerInterval);
|
|
this.spinnerInterval = null;
|
|
this.clearLine();
|
|
}
|
|
}
|
|
|
|
private write(text: string): void {
|
|
stdout.write(text);
|
|
}
|
|
|
|
private clearLine(): void {
|
|
this.write('\r\x1b[K');
|
|
}
|
|
|
|
private formatResponse(text: string): string {
|
|
// Simple markdown-like formatting for CLI
|
|
return text
|
|
.replace(/\*\*(.+?)\*\*/g, this.format('$1', 'bold'))
|
|
.replace(/`(.+?)`/g, this.format('$1', 'cyan'))
|
|
.replace(/^# (.+)$/gm, this.format('$1', 'bold'))
|
|
.replace(/^## (.+)$/gm, this.format('$1', 'bold'))
|
|
.replace(/^- /gm, ' - ');
|
|
}
|
|
|
|
private format(text: string, style: string): string {
|
|
if (!this.config.colors) return text;
|
|
|
|
const styles: Record<string, string> = {
|
|
bold: '\x1b[1m',
|
|
dim: '\x1b[2m',
|
|
red: '\x1b[31m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
cyan: '\x1b[36m',
|
|
reset: '\x1b[0m',
|
|
};
|
|
|
|
const code = styles[style] || '';
|
|
return code ? `${code}${text}${styles.reset}` : text;
|
|
}
|
|
|
|
private formatBox(lines: string[]): string {
|
|
const maxLen = Math.max(...lines.map(l => l.length));
|
|
const top = '+' + '-'.repeat(maxLen + 2) + '+';
|
|
const bottom = top;
|
|
const formatted = lines.map(l => `| ${l.padEnd(maxLen)} |`);
|
|
return [top, ...formatted, bottom].join('\n');
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Factory Function
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a CLI surface instance.
|
|
*/
|
|
export function createCLISurface(config?: Partial<CLISurfaceConfig>): CLISurface {
|
|
return new CLISurface(config);
|
|
}
|