nova-terminal-assistant 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nova-terminal-assistant might be problematic. Click here for more details.
- package/README.md +358 -0
- package/bin/nova +38 -0
- package/bin/nova.js +12 -0
- package/package.json +67 -0
- package/src/cli/commands/SmartCompletion.ts +458 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/startup/IFlowRepl.ts +212 -0
- package/src/cli/startup/InkBasedRepl.ts +1056 -0
- package/src/cli/startup/InteractiveRepl.ts +2833 -0
- package/src/cli/startup/NovaApp.ts +1861 -0
- package/src/cli/startup/index.ts +4 -0
- package/src/cli/startup/parseArgs.ts +293 -0
- package/src/cli/test-modules.ts +27 -0
- package/src/cli/ui/IFlowDropdown.ts +425 -0
- package/src/cli/ui/ModernReplUI.ts +276 -0
- package/src/cli/ui/SimpleSelector2.ts +215 -0
- package/src/cli/ui/components/ConfirmDialog.ts +176 -0
- package/src/cli/ui/components/ErrorPanel.ts +364 -0
- package/src/cli/ui/components/InkAppRunner.tsx +67 -0
- package/src/cli/ui/components/InkComponents.tsx +613 -0
- package/src/cli/ui/components/NovaInkApp.tsx +312 -0
- package/src/cli/ui/components/ProgressBar.ts +177 -0
- package/src/cli/ui/components/ProgressIndicator.ts +298 -0
- package/src/cli/ui/components/QuickActions.ts +396 -0
- package/src/cli/ui/components/SimpleErrorPanel.ts +231 -0
- package/src/cli/ui/components/StatusBar.ts +194 -0
- package/src/cli/ui/components/ThinkingBlockRenderer.ts +401 -0
- package/src/cli/ui/components/index.ts +27 -0
- package/src/cli/ui/ink-prototype.tsx +347 -0
- package/src/cli/utils/CliUI.ts +336 -0
- package/src/cli/utils/CompletionHelper.ts +388 -0
- package/src/cli/utils/EnhancedCompleter.test.ts +226 -0
- package/src/cli/utils/EnhancedCompleter.ts +513 -0
- package/src/cli/utils/ErrorEnhancer.ts +429 -0
- package/src/cli/utils/OutputFormatter.ts +193 -0
- package/src/cli/utils/index.ts +9 -0
- package/src/core/agents/AgentOrchestrator.ts +515 -0
- package/src/core/agents/index.ts +17 -0
- package/src/core/audit/AuditLogger.ts +509 -0
- package/src/core/audit/index.ts +11 -0
- package/src/core/auth/AuthManager.d.ts.map +1 -0
- package/src/core/auth/AuthManager.ts +138 -0
- package/src/core/auth/index.d.ts.map +1 -0
- package/src/core/auth/index.ts +2 -0
- package/src/core/config/ConfigManager.d.ts.map +1 -0
- package/src/core/config/ConfigManager.test.ts +183 -0
- package/src/core/config/ConfigManager.ts +1219 -0
- package/src/core/config/index.d.ts.map +1 -0
- package/src/core/config/index.ts +1 -0
- package/src/core/context/ContextBuilder.d.ts.map +1 -0
- package/src/core/context/ContextBuilder.ts +171 -0
- package/src/core/context/ContextCompressor.d.ts.map +1 -0
- package/src/core/context/ContextCompressor.ts +642 -0
- package/src/core/context/LayeredMemoryManager.ts +657 -0
- package/src/core/context/MemoryDiscovery.d.ts.map +1 -0
- package/src/core/context/MemoryDiscovery.ts +175 -0
- package/src/core/context/defaultSystemPrompt.d.ts.map +1 -0
- package/src/core/context/defaultSystemPrompt.ts +35 -0
- package/src/core/context/index.d.ts.map +1 -0
- package/src/core/context/index.ts +22 -0
- package/src/core/extensions/SkillGenerator.ts +421 -0
- package/src/core/extensions/SkillInstaller.d.ts.map +1 -0
- package/src/core/extensions/SkillInstaller.ts +257 -0
- package/src/core/extensions/SkillRegistry.d.ts.map +1 -0
- package/src/core/extensions/SkillRegistry.ts +361 -0
- package/src/core/extensions/SkillValidator.ts +525 -0
- package/src/core/extensions/index.ts +15 -0
- package/src/core/index.d.ts.map +1 -0
- package/src/core/index.ts +42 -0
- package/src/core/mcp/McpManager.d.ts.map +1 -0
- package/src/core/mcp/McpManager.ts +632 -0
- package/src/core/mcp/index.d.ts.map +1 -0
- package/src/core/mcp/index.ts +2 -0
- package/src/core/model/ModelClient.d.ts.map +1 -0
- package/src/core/model/ModelClient.ts +217 -0
- package/src/core/model/ModelConnectionTester.ts +363 -0
- package/src/core/model/ModelValidator.ts +348 -0
- package/src/core/model/index.d.ts.map +1 -0
- package/src/core/model/index.ts +6 -0
- package/src/core/model/providers/AnthropicProvider.d.ts.map +1 -0
- package/src/core/model/providers/AnthropicProvider.ts +279 -0
- package/src/core/model/providers/CodingPlanProvider.d.ts.map +1 -0
- package/src/core/model/providers/CodingPlanProvider.ts +210 -0
- package/src/core/model/providers/OllamaCloudProvider.d.ts.map +1 -0
- package/src/core/model/providers/OllamaCloudProvider.ts +405 -0
- package/src/core/model/providers/OllamaManager.d.ts.map +1 -0
- package/src/core/model/providers/OllamaManager.ts +201 -0
- package/src/core/model/providers/OllamaProvider.d.ts.map +1 -0
- package/src/core/model/providers/OllamaProvider.ts +73 -0
- package/src/core/model/providers/OpenAICompatibleProvider.d.ts.map +1 -0
- package/src/core/model/providers/OpenAICompatibleProvider.ts +327 -0
- package/src/core/model/providers/OpenAIProvider.d.ts.map +1 -0
- package/src/core/model/providers/OpenAIProvider.ts +29 -0
- package/src/core/model/providers/index.d.ts.map +1 -0
- package/src/core/model/providers/index.ts +12 -0
- package/src/core/model/types.d.ts.map +1 -0
- package/src/core/model/types.ts +77 -0
- package/src/core/security/ApprovalManager.d.ts.map +1 -0
- package/src/core/security/ApprovalManager.ts +174 -0
- package/src/core/security/FileFilter.d.ts.map +1 -0
- package/src/core/security/FileFilter.ts +141 -0
- package/src/core/security/HookExecutor.d.ts.map +1 -0
- package/src/core/security/HookExecutor.ts +178 -0
- package/src/core/security/SandboxExecutor.ts +447 -0
- package/src/core/security/index.d.ts.map +1 -0
- package/src/core/security/index.ts +8 -0
- package/src/core/session/AgentLoop.d.ts.map +1 -0
- package/src/core/session/AgentLoop.ts +501 -0
- package/src/core/session/SessionManager.d.ts.map +1 -0
- package/src/core/session/SessionManager.test.ts +183 -0
- package/src/core/session/SessionManager.ts +460 -0
- package/src/core/session/index.d.ts.map +1 -0
- package/src/core/session/index.ts +3 -0
- package/src/core/telemetry/Telemetry.d.ts.map +1 -0
- package/src/core/telemetry/Telemetry.ts +90 -0
- package/src/core/telemetry/TelemetryService.ts +531 -0
- package/src/core/telemetry/index.d.ts.map +1 -0
- package/src/core/telemetry/index.ts +12 -0
- package/src/core/testing/AutoFixer.ts +385 -0
- package/src/core/testing/ErrorAnalyzer.ts +499 -0
- package/src/core/testing/TestRunner.ts +265 -0
- package/src/core/testing/agent-cli-tests.ts +538 -0
- package/src/core/testing/index.ts +11 -0
- package/src/core/tools/ToolRegistry.d.ts.map +1 -0
- package/src/core/tools/ToolRegistry.test.ts +206 -0
- package/src/core/tools/ToolRegistry.ts +260 -0
- package/src/core/tools/impl/EditFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/EditFileTool.ts +97 -0
- package/src/core/tools/impl/ListDirectoryTool.d.ts.map +1 -0
- package/src/core/tools/impl/ListDirectoryTool.ts +142 -0
- package/src/core/tools/impl/MemoryTool.d.ts.map +1 -0
- package/src/core/tools/impl/MemoryTool.ts +102 -0
- package/src/core/tools/impl/ReadFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/ReadFileTool.ts +58 -0
- package/src/core/tools/impl/SearchContentTool.d.ts.map +1 -0
- package/src/core/tools/impl/SearchContentTool.ts +94 -0
- package/src/core/tools/impl/SearchFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/SearchFileTool.ts +61 -0
- package/src/core/tools/impl/ShellTool.d.ts.map +1 -0
- package/src/core/tools/impl/ShellTool.ts +118 -0
- package/src/core/tools/impl/TaskTool.d.ts.map +1 -0
- package/src/core/tools/impl/TaskTool.ts +207 -0
- package/src/core/tools/impl/TodoTool.d.ts.map +1 -0
- package/src/core/tools/impl/TodoTool.ts +122 -0
- package/src/core/tools/impl/WebFetchTool.d.ts.map +1 -0
- package/src/core/tools/impl/WebFetchTool.ts +103 -0
- package/src/core/tools/impl/WebSearchTool.d.ts.map +1 -0
- package/src/core/tools/impl/WebSearchTool.ts +89 -0
- package/src/core/tools/impl/WriteFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/WriteFileTool.ts +49 -0
- package/src/core/tools/impl/index.d.ts.map +1 -0
- package/src/core/tools/impl/index.ts +16 -0
- package/src/core/tools/index.d.ts.map +1 -0
- package/src/core/tools/index.ts +7 -0
- package/src/core/tools/schemas/execution.d.ts.map +1 -0
- package/src/core/tools/schemas/execution.ts +42 -0
- package/src/core/tools/schemas/file.d.ts.map +1 -0
- package/src/core/tools/schemas/file.ts +119 -0
- package/src/core/tools/schemas/index.d.ts.map +1 -0
- package/src/core/tools/schemas/index.ts +11 -0
- package/src/core/tools/schemas/memory.d.ts.map +1 -0
- package/src/core/tools/schemas/memory.ts +52 -0
- package/src/core/tools/schemas/orchestration.d.ts.map +1 -0
- package/src/core/tools/schemas/orchestration.ts +44 -0
- package/src/core/tools/schemas/search.d.ts.map +1 -0
- package/src/core/tools/schemas/search.ts +112 -0
- package/src/core/tools/schemas/todo.d.ts.map +1 -0
- package/src/core/tools/schemas/todo.ts +32 -0
- package/src/core/tools/schemas/web.d.ts.map +1 -0
- package/src/core/tools/schemas/web.ts +86 -0
- package/src/core/types/config.d.ts.map +1 -0
- package/src/core/types/config.ts +200 -0
- package/src/core/types/errors.d.ts.map +1 -0
- package/src/core/types/errors.ts +204 -0
- package/src/core/types/index.d.ts.map +1 -0
- package/src/core/types/index.ts +8 -0
- package/src/core/types/session.d.ts.map +1 -0
- package/src/core/types/session.ts +216 -0
- package/src/core/types/tools.d.ts.map +1 -0
- package/src/core/types/tools.ts +157 -0
- package/src/core/utils/CheckpointManager.d.ts.map +1 -0
- package/src/core/utils/CheckpointManager.ts +327 -0
- package/src/core/utils/Logger.d.ts.map +1 -0
- package/src/core/utils/Logger.ts +98 -0
- package/src/core/utils/RetryManager.ts +471 -0
- package/src/core/utils/TokenCounter.d.ts.map +1 -0
- package/src/core/utils/TokenCounter.ts +414 -0
- package/src/core/utils/VectorMemoryStore.ts +440 -0
- package/src/core/utils/helpers.d.ts.map +1 -0
- package/src/core/utils/helpers.ts +89 -0
- package/src/core/utils/index.d.ts.map +1 -0
- package/src/core/utils/index.ts +19 -0
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// InkBasedRepl - Interactive REPL using Ink UI framework
|
|
3
|
+
// A modern, component-based terminal UI similar to Claude Code
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import { spawn, execSync } from 'node:child_process';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { render } from 'ink';
|
|
13
|
+
import type { NovaConfig } from '../core/types/config.js';
|
|
14
|
+
import type { SessionId, ApprovalRequest, ApprovalResponse } from '../core/types/session.js';
|
|
15
|
+
import type { McpManager, McpServerStatus } from '../core/mcp/McpManager.js';
|
|
16
|
+
import type { SkillRegistry, SkillDefinition } from '../core/extensions/SkillRegistry.js';
|
|
17
|
+
import type { ConfigManager } from '../core/config/ConfigManager.js';
|
|
18
|
+
import type { AuthManager } from '../core/auth/AuthManager.js';
|
|
19
|
+
import { AgentLoop } from '../core/session/AgentLoop.js';
|
|
20
|
+
import { ModelClient } from '../core/model/ModelClient.js';
|
|
21
|
+
import { SessionManager } from '../core/session/SessionManager.js';
|
|
22
|
+
import { ToolRegistry } from '../core/tools/ToolRegistry.js';
|
|
23
|
+
import { ApprovalManager } from '../core/security/ApprovalManager.js';
|
|
24
|
+
import { buildSystemPrompt } from '../core/context/defaultSystemPrompt.js';
|
|
25
|
+
import { ThinkingBlockRenderer } from '../ui/components/ThinkingBlockRenderer.js';
|
|
26
|
+
import { selectModelInteractive, selectSkillInteractive } from '../ui/SimpleSelector2.js';
|
|
27
|
+
|
|
28
|
+
// Import Ink components
|
|
29
|
+
import {
|
|
30
|
+
NovaInkApp,
|
|
31
|
+
Spinner,
|
|
32
|
+
StatusBar,
|
|
33
|
+
InputBox,
|
|
34
|
+
MessageList,
|
|
35
|
+
ToolCallPanel,
|
|
36
|
+
ThinkingBlock,
|
|
37
|
+
ProgressBar,
|
|
38
|
+
ConfirmDialog,
|
|
39
|
+
SelectList,
|
|
40
|
+
Toast,
|
|
41
|
+
Colors,
|
|
42
|
+
} from '../ui/components/index.js';
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Types
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
export interface ReplOptions {
|
|
49
|
+
modelClient: ModelClient;
|
|
50
|
+
sessionManager: SessionManager;
|
|
51
|
+
toolRegistry: ToolRegistry;
|
|
52
|
+
approvalManager: ApprovalManager;
|
|
53
|
+
authManager: AuthManager;
|
|
54
|
+
config: NovaConfig;
|
|
55
|
+
configManager: ConfigManager;
|
|
56
|
+
cwd: string;
|
|
57
|
+
contextCompressor?: any;
|
|
58
|
+
mcpManager?: McpManager;
|
|
59
|
+
skillRegistry?: SkillRegistry;
|
|
60
|
+
restoreSessionId?: SessionId;
|
|
61
|
+
/** --json: output in JSON format for machine parsing */
|
|
62
|
+
json?: boolean;
|
|
63
|
+
/** --no-input: non-interactive mode, never prompt for input */
|
|
64
|
+
noInput?: boolean;
|
|
65
|
+
/** --limit: maximum number of items to display */
|
|
66
|
+
limit?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type InteractionMode = 'auto' | 'plan' | 'ask';
|
|
70
|
+
|
|
71
|
+
interface Message {
|
|
72
|
+
id: string;
|
|
73
|
+
role: 'user' | 'assistant' | 'tool';
|
|
74
|
+
content: string;
|
|
75
|
+
timestamp: Date;
|
|
76
|
+
isThinking?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ToolCall {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
status: 'pending' | 'running' | 'success' | 'error';
|
|
83
|
+
duration?: number;
|
|
84
|
+
input?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface AppState {
|
|
88
|
+
model: string;
|
|
89
|
+
mode: InteractionMode;
|
|
90
|
+
contextUsage: number;
|
|
91
|
+
sessionId: string;
|
|
92
|
+
messages: Message[];
|
|
93
|
+
activeTools: ToolCall[];
|
|
94
|
+
processing: boolean;
|
|
95
|
+
thinkingContent: string;
|
|
96
|
+
showThinking: boolean;
|
|
97
|
+
mcpConnected: number;
|
|
98
|
+
mcpTotal: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Box drawing characters
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
const BOX = {
|
|
106
|
+
tl: '╭', tr: '╮', bl: '╰', br: '╯',
|
|
107
|
+
h: '─', v: '│', ht: '├', htr: '┤',
|
|
108
|
+
hThick: '━', vThick: '┃',
|
|
109
|
+
arrow: '›', bullet: '•', check: '✓', crossX: '✗', dot: '·',
|
|
110
|
+
diamond: '◆', star: '★',
|
|
111
|
+
spinner: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const MODE_LABELS = {
|
|
115
|
+
auto: { label: 'AUTO', color: chalk.green.bold, description: 'Full autonomous' },
|
|
116
|
+
plan: { label: 'PLAN', color: chalk.yellow.bold, description: 'Plan first' },
|
|
117
|
+
ask: { label: 'ASK', color: chalk.cyan.bold, description: 'Answer only' },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// InkBasedRepl Class
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
export class InkBasedRepl {
|
|
125
|
+
private modelClient: ModelClient;
|
|
126
|
+
private sessionManager: SessionManager;
|
|
127
|
+
private toolRegistry: ToolRegistry;
|
|
128
|
+
private approvalManager: ApprovalManager;
|
|
129
|
+
private authManager: AuthManager;
|
|
130
|
+
private config: NovaConfig;
|
|
131
|
+
private configManager: ConfigManager;
|
|
132
|
+
private cwd: string;
|
|
133
|
+
private contextCompressor?: any;
|
|
134
|
+
private mcpManager?: McpManager;
|
|
135
|
+
private skillRegistry?: SkillRegistry;
|
|
136
|
+
private sessionId: SessionId | null = null;
|
|
137
|
+
private restoreSessionId?: SessionId;
|
|
138
|
+
|
|
139
|
+
private currentLoop: AgentLoop | null = null;
|
|
140
|
+
private state: AppState;
|
|
141
|
+
private _pendingSkillInject: SkillDefinition | null = null;
|
|
142
|
+
|
|
143
|
+
// Thinking renderer for non-Ink fallback
|
|
144
|
+
private thinkingRenderer: ThinkingBlockRenderer;
|
|
145
|
+
|
|
146
|
+
// UI state
|
|
147
|
+
private showHelp = false;
|
|
148
|
+
private showModelSelector = false;
|
|
149
|
+
private processing = false;
|
|
150
|
+
private currentText: string = '';
|
|
151
|
+
private pendingToolCalls: Map<string, { name: string; startTime: number }> = new Map();
|
|
152
|
+
|
|
153
|
+
// Store initial config for recreating ModelClient
|
|
154
|
+
private initialConfig: NovaConfig;
|
|
155
|
+
|
|
156
|
+
// Agent-first options
|
|
157
|
+
private json: boolean;
|
|
158
|
+
private noInput: boolean;
|
|
159
|
+
private limit: number;
|
|
160
|
+
|
|
161
|
+
constructor(options: ReplOptions) {
|
|
162
|
+
this.modelClient = options.modelClient;
|
|
163
|
+
this.sessionManager = options.sessionManager;
|
|
164
|
+
this.toolRegistry = options.toolRegistry;
|
|
165
|
+
this.approvalManager = options.approvalManager;
|
|
166
|
+
this.authManager = options.authManager;
|
|
167
|
+
this.config = options.config;
|
|
168
|
+
this.initialConfig = JSON.parse(JSON.stringify(options.config)); // Deep copy
|
|
169
|
+
this.configManager = options.configManager;
|
|
170
|
+
this.cwd = options.cwd;
|
|
171
|
+
this.contextCompressor = options.contextCompressor;
|
|
172
|
+
this.mcpManager = options.mcpManager;
|
|
173
|
+
this.skillRegistry = options.skillRegistry;
|
|
174
|
+
this.restoreSessionId = options.restoreSessionId;
|
|
175
|
+
|
|
176
|
+
// Agent-first options
|
|
177
|
+
this.json = options.json ?? false;
|
|
178
|
+
this.noInput = options.noInput ?? false;
|
|
179
|
+
this.limit = options.limit ?? 20; // Default limit for bounded output
|
|
180
|
+
|
|
181
|
+
this.thinkingRenderer = new ThinkingBlockRenderer({
|
|
182
|
+
expanded: false,
|
|
183
|
+
maxPreviewLines: 4,
|
|
184
|
+
maxLineLength: 80,
|
|
185
|
+
showStreamingPreview: false,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Initialize state
|
|
189
|
+
this.state = {
|
|
190
|
+
model: this.modelClient.getModel(),
|
|
191
|
+
mode: (this.config.core.defaultApprovalMode === 'yolo' ? 'auto' :
|
|
192
|
+
this.config.core.defaultApprovalMode === 'plan' ? 'plan' : 'ask') as InteractionMode,
|
|
193
|
+
contextUsage: 0,
|
|
194
|
+
sessionId: 'new',
|
|
195
|
+
messages: [],
|
|
196
|
+
activeTools: [],
|
|
197
|
+
processing: false,
|
|
198
|
+
thinkingContent: '',
|
|
199
|
+
showThinking: true,
|
|
200
|
+
mcpConnected: 0,
|
|
201
|
+
mcpTotal: 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ========================================================================
|
|
206
|
+
// Lifecycle
|
|
207
|
+
// ========================================================================
|
|
208
|
+
|
|
209
|
+
async start(): Promise<void> {
|
|
210
|
+
// Restore or create session
|
|
211
|
+
if (this.restoreSessionId) {
|
|
212
|
+
const existing = this.sessionManager.get(this.restoreSessionId);
|
|
213
|
+
this.sessionId = existing ? this.restoreSessionId : this.createInitialSession();
|
|
214
|
+
} else {
|
|
215
|
+
this.sessionId = this.createInitialSession();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Set approval handler
|
|
219
|
+
this.approvalManager.setHandler(this.handleApproval.bind(this));
|
|
220
|
+
|
|
221
|
+
// Print initial banner (simplified)
|
|
222
|
+
this.printBanner();
|
|
223
|
+
|
|
224
|
+
// Start interactive loop
|
|
225
|
+
await this.runInputLoop();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private createInitialSession(): SessionId {
|
|
229
|
+
const session = this.sessionManager.create({
|
|
230
|
+
workingDirectory: this.cwd,
|
|
231
|
+
model: this.modelClient.getModel(),
|
|
232
|
+
maxTokens: this.config.core.maxTokens,
|
|
233
|
+
temperature: this.config.core.temperature,
|
|
234
|
+
approvalMode: this.getEffectiveApprovalMode() as any,
|
|
235
|
+
streaming: true,
|
|
236
|
+
maxTurns: this.config.core.maxTurns,
|
|
237
|
+
});
|
|
238
|
+
return session.id;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ========================================================================
|
|
242
|
+
// Agent-First: JSON Output Helpers
|
|
243
|
+
// ========================================================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Output structured JSON data when --json flag is set
|
|
247
|
+
* This enables machine-parseable output for Agent consumers
|
|
248
|
+
*/
|
|
249
|
+
private outputJSON(data: any): void {
|
|
250
|
+
if (this.json) {
|
|
251
|
+
console.log(JSON.stringify(data, null, 2));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Output structured error in JSON format
|
|
257
|
+
*/
|
|
258
|
+
private outputErrorJSON(code: string, message: string, suggestion?: string, example?: string): void {
|
|
259
|
+
if (this.json) {
|
|
260
|
+
console.log(JSON.stringify({
|
|
261
|
+
error: {
|
|
262
|
+
code,
|
|
263
|
+
message,
|
|
264
|
+
suggestion,
|
|
265
|
+
example
|
|
266
|
+
}
|
|
267
|
+
}, null, 2));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Check if we should suppress interactive elements
|
|
273
|
+
*/
|
|
274
|
+
private shouldSuppressInteractive(): boolean {
|
|
275
|
+
return this.noInput || this.json;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Apply limit to array results for bounded output
|
|
280
|
+
*/
|
|
281
|
+
private applyLimit<T>(items: T[]): { items: T[]; total: number; showing: string; hint?: string } {
|
|
282
|
+
const total = items.length;
|
|
283
|
+
const limited = items.slice(0, this.limit);
|
|
284
|
+
const showing = `Showing ${limited.length} of ${total}`;
|
|
285
|
+
const hint = total > this.limit ? `Use --limit ${total} to see all items` : undefined;
|
|
286
|
+
|
|
287
|
+
return { items: limited, total, showing, hint };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ========================================================================
|
|
291
|
+
// Banner & UI
|
|
292
|
+
// ========================================================================
|
|
293
|
+
|
|
294
|
+
private printBanner(): void {
|
|
295
|
+
const w = 70;
|
|
296
|
+
const hr = chalk.hex('#7C3AED').dim(BOX.h.repeat(w));
|
|
297
|
+
const hrThick = chalk.hex('#7C3AED')(BOX.hThick.repeat(w));
|
|
298
|
+
const vl = chalk.hex('#7C3AED').dim(BOX.v);
|
|
299
|
+
|
|
300
|
+
const modelShort = this.modelClient.getModel().split('/').pop() || this.modelClient.getModel();
|
|
301
|
+
const modeInfo = MODE_LABELS[this.state.mode];
|
|
302
|
+
|
|
303
|
+
console.log('');
|
|
304
|
+
console.log(chalk.hex('#7C3AED')(BOX.tl) + hrThick + chalk.hex('#7C3AED')(BOX.tr));
|
|
305
|
+
|
|
306
|
+
// Logo
|
|
307
|
+
console.log(vl + chalk.hex('#7C3AED').bold(' NOVA ') +
|
|
308
|
+
chalk.hex('#A78BFA')('CLI') +
|
|
309
|
+
chalk.dim(' · AI-powered terminal assistant') + ' '.repeat(24) + vl);
|
|
310
|
+
|
|
311
|
+
console.log(chalk.hex('#7C3AED')(BOX.ht) + hr + chalk.hex('#7C3AED')(BOX.htr));
|
|
312
|
+
|
|
313
|
+
// Status line
|
|
314
|
+
console.log(vl + ' Model: ' + chalk.white(modelShort) + ' '.repeat(Math.max(0, 52 - modelShort.length)) + vl);
|
|
315
|
+
console.log(vl + ' Mode: ' + modeInfo.color(modeInfo.label) + ' '.repeat(52) + vl);
|
|
316
|
+
console.log(vl + ' Dir: ' + chalk.gray(this.cwd.slice(-50)) + ' '.repeat(Math.max(0, 52 - Math.min(50, this.cwd.length))) + vl);
|
|
317
|
+
|
|
318
|
+
console.log(chalk.hex('#7C3AED')(BOX.bl) + hrThick + chalk.hex('#7C3AED')(BOX.br));
|
|
319
|
+
console.log('');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private printPrompt(): void {
|
|
323
|
+
const modeInfo = MODE_LABELS[this.state.mode];
|
|
324
|
+
const modelShort = this.modelClient.getModel().split('/').pop() || this.modelClient.getModel();
|
|
325
|
+
|
|
326
|
+
const modeBadge = modeInfo.color(`[${modeInfo.label}]`);
|
|
327
|
+
const ctxStr = this.state.contextUsage > 0 ?
|
|
328
|
+
chalk.dim(` (${this.state.contextUsage}% ctx)`) : '';
|
|
329
|
+
|
|
330
|
+
process.stdout.write(`\n${modeBadge} ${chalk.gray(modelShort)}${ctxStr} ${chalk.hex('#7C3AED')('›')} `);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ========================================================================
|
|
334
|
+
// Input Loop
|
|
335
|
+
// ========================================================================
|
|
336
|
+
|
|
337
|
+
private async runInputLoop(): Promise<void> {
|
|
338
|
+
const readline = await import('node:readline');
|
|
339
|
+
const rl = readline.createInterface({
|
|
340
|
+
input: process.stdin,
|
|
341
|
+
output: process.stdout,
|
|
342
|
+
historySize: 200,
|
|
343
|
+
removeHistoryDuplicates: true,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Guard flag: when true, rl 'close' is a false alarm from raw-mode pause/resume
|
|
347
|
+
let closeGuard = false;
|
|
348
|
+
|
|
349
|
+
// Handle Ctrl+C
|
|
350
|
+
process.on('SIGINT', () => {
|
|
351
|
+
if (this.currentLoop?.isActive()) {
|
|
352
|
+
this.currentLoop.cancel();
|
|
353
|
+
this.processing = false;
|
|
354
|
+
process.stdout.write('\n');
|
|
355
|
+
this.printPrompt();
|
|
356
|
+
} else {
|
|
357
|
+
console.log(chalk.dim('\n Use /quit or Ctrl+D to exit'));
|
|
358
|
+
this.printPrompt();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
rl.on('close', () => {
|
|
363
|
+
// Ignore close events triggered by raw-mode pause/resume during
|
|
364
|
+
// interactive selectors (SimpleSelector2, etc.)
|
|
365
|
+
if (closeGuard) {
|
|
366
|
+
closeGuard = false;
|
|
367
|
+
process.stdin.resume();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (this.sessionId) this.sessionManager.persist(this.sessionId);
|
|
371
|
+
console.log(chalk.dim('\nGoodbye!'));
|
|
372
|
+
process.exit(0);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Main loop
|
|
376
|
+
while (true) {
|
|
377
|
+
this.printPrompt();
|
|
378
|
+
|
|
379
|
+
const input = await new Promise<string>((resolve) => {
|
|
380
|
+
rl.question('', resolve);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (!input.trim()) continue;
|
|
384
|
+
|
|
385
|
+
// Set guard before dispatching — commands may enter/leave raw mode
|
|
386
|
+
closeGuard = true;
|
|
387
|
+
this.processing = true;
|
|
388
|
+
await this.dispatchInput(input.trim());
|
|
389
|
+
this.processing = false;
|
|
390
|
+
closeGuard = false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private async dispatchInput(input: string): Promise<void> {
|
|
395
|
+
// Handle commands
|
|
396
|
+
if (input.startsWith('/')) {
|
|
397
|
+
await this.handleCommand(input);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Handle shell commands
|
|
402
|
+
if (input.startsWith('!')) {
|
|
403
|
+
await this.handleShellCommand(input.slice(1).trim());
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Process normal input
|
|
408
|
+
await this.processInput(input);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ========================================================================
|
|
412
|
+
// Command Handlers
|
|
413
|
+
// ========================================================================
|
|
414
|
+
|
|
415
|
+
private async handleCommand(cmd: string): Promise<void> {
|
|
416
|
+
const parts = cmd.slice(1).split(/\s+/);
|
|
417
|
+
const command = (parts[0] || '').toLowerCase();
|
|
418
|
+
const arg = parts.slice(1).join(' ');
|
|
419
|
+
|
|
420
|
+
switch (command) {
|
|
421
|
+
case 'quit':
|
|
422
|
+
case 'exit':
|
|
423
|
+
case 'q':
|
|
424
|
+
if (this.sessionId) this.sessionManager.persist(this.sessionId);
|
|
425
|
+
console.log(chalk.dim('Goodbye!'));
|
|
426
|
+
process.exit(0);
|
|
427
|
+
|
|
428
|
+
case 'help':
|
|
429
|
+
case 'h':
|
|
430
|
+
case '?':
|
|
431
|
+
this.printHelp();
|
|
432
|
+
break;
|
|
433
|
+
|
|
434
|
+
case 'clear':
|
|
435
|
+
if (this.sessionId) this.sessionManager.persist(this.sessionId);
|
|
436
|
+
this.sessionId = this.createInitialSession();
|
|
437
|
+
this.state.messages = [];
|
|
438
|
+
this.state.contextUsage = 0;
|
|
439
|
+
console.log(chalk.dim(' Conversation cleared.'));
|
|
440
|
+
break;
|
|
441
|
+
|
|
442
|
+
case 'mode':
|
|
443
|
+
if (arg && ['auto', 'plan', 'ask'].includes(arg)) {
|
|
444
|
+
this.state.mode = arg as InteractionMode;
|
|
445
|
+
console.log(chalk.dim(` Mode: ${arg}`));
|
|
446
|
+
} else {
|
|
447
|
+
// Cycle mode
|
|
448
|
+
const modes: InteractionMode[] = ['auto', 'plan', 'ask'];
|
|
449
|
+
const idx = modes.indexOf(this.state.mode);
|
|
450
|
+
this.state.mode = modes[(idx + 1) % 3] as InteractionMode;
|
|
451
|
+
console.log(chalk.dim(` Mode: ${this.state.mode}`));
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
|
|
455
|
+
case 'model':
|
|
456
|
+
await this.handleModelCommand(arg);
|
|
457
|
+
break;
|
|
458
|
+
|
|
459
|
+
case 'ollama':
|
|
460
|
+
await this.handleOllamaCommand(arg);
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case 'status':
|
|
464
|
+
this.printStatus();
|
|
465
|
+
break;
|
|
466
|
+
|
|
467
|
+
case 'mcp':
|
|
468
|
+
await this.handleMcpCommand(arg);
|
|
469
|
+
break;
|
|
470
|
+
|
|
471
|
+
case 'skills':
|
|
472
|
+
await this.handleSkillsCommand(arg);
|
|
473
|
+
break;
|
|
474
|
+
|
|
475
|
+
case 'thinking':
|
|
476
|
+
this.state.showThinking = !this.state.showThinking;
|
|
477
|
+
console.log(chalk.dim(` Thinking: ${this.state.showThinking ? 'ON' : 'OFF'}`));
|
|
478
|
+
break;
|
|
479
|
+
|
|
480
|
+
default:
|
|
481
|
+
console.log(chalk.yellow(` Unknown command: /${command}`));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private printHelp(): void {
|
|
486
|
+
console.log('');
|
|
487
|
+
console.log(chalk.hex('#7C3AED').bold(' Commands:'));
|
|
488
|
+
console.log(chalk.gray(' /help, /h Show this help'));
|
|
489
|
+
console.log(chalk.gray(' /quit, /q Exit Nova CLI'));
|
|
490
|
+
console.log(chalk.gray(' /clear Clear conversation'));
|
|
491
|
+
console.log(chalk.gray(' /mode Cycle mode (AUTO → PLAN → ASK)'));
|
|
492
|
+
console.log(chalk.gray(' /model Switch model'));
|
|
493
|
+
console.log(chalk.gray(' /ollama Ollama status'));
|
|
494
|
+
console.log(chalk.gray(' /status Session status'));
|
|
495
|
+
console.log(chalk.gray(' /mcp MCP servers'));
|
|
496
|
+
console.log(chalk.gray(' /skills Available skills'));
|
|
497
|
+
console.log(chalk.gray(' /thinking Toggle thinking display'));
|
|
498
|
+
console.log('');
|
|
499
|
+
console.log(chalk.gray(' @file Inject file content'));
|
|
500
|
+
console.log(chalk.gray(' !command Run shell command'));
|
|
501
|
+
console.log('');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private async handleModelCommand(arg: string): Promise<void> {
|
|
505
|
+
if (!arg) {
|
|
506
|
+
// Interactive model selection — only show configured providers
|
|
507
|
+
const config = this.configManager.getConfig();
|
|
508
|
+
const models: Array<{provider: string, model: string, description?: string}> = [];
|
|
509
|
+
|
|
510
|
+
// Collect models only from configured/available providers
|
|
511
|
+
for (const [provider, providerConfig] of Object.entries(config.models.providers)) {
|
|
512
|
+
const hasCreds = this.authManager.hasCredentials(provider);
|
|
513
|
+
const isOllama = providerConfig.type === 'ollama';
|
|
514
|
+
const isOllamaCloud = providerConfig.type === 'ollama-cloud';
|
|
515
|
+
|
|
516
|
+
// Skip providers that are not configured
|
|
517
|
+
if (!hasCreds && !isOllama && !isOllamaCloud) continue;
|
|
518
|
+
|
|
519
|
+
// For Ollama, verify it's actually running
|
|
520
|
+
if (isOllama && !hasCreds) {
|
|
521
|
+
try {
|
|
522
|
+
const baseUrl = process.env.OLLAMA_HOST || 'http://localhost:11434';
|
|
523
|
+
const resp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) });
|
|
524
|
+
if (!resp.ok) continue; // Ollama not running
|
|
525
|
+
} catch {
|
|
526
|
+
continue; // Ollama not running
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
for (const [modelId, modelConfig] of Object.entries(providerConfig.models)) {
|
|
531
|
+
const features: string[] = [];
|
|
532
|
+
if (modelConfig.supportsVision) features.push('vision');
|
|
533
|
+
if (modelConfig.supportsTools) features.push('tools');
|
|
534
|
+
if (modelConfig.supportsThinking) features.push('thinking');
|
|
535
|
+
|
|
536
|
+
const description = features.length > 0 ? `[${features.join(', ')}]` : '';
|
|
537
|
+
models.push({
|
|
538
|
+
provider,
|
|
539
|
+
model: modelId,
|
|
540
|
+
description,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (models.length === 0) {
|
|
546
|
+
console.log(chalk.yellow(' No models available. Configure a provider first.'));
|
|
547
|
+
console.log(chalk.gray(' Use: nova auth set <provider>'));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Show interactive selector (returns "provider/model" format)
|
|
552
|
+
const selectedModel = await selectModelInteractive(models);
|
|
553
|
+
|
|
554
|
+
if (selectedModel && selectedModel !== 'separator' && !selectedModel.startsWith('provider:')) {
|
|
555
|
+
const success = await this.switchModel(selectedModel);
|
|
556
|
+
if (success) {
|
|
557
|
+
console.log(chalk.green(` ✓ Switched to: ${selectedModel}`));
|
|
558
|
+
}
|
|
559
|
+
// If failed, switchModel already printed an error message
|
|
560
|
+
} else if (selectedModel && selectedModel.startsWith('provider:')) {
|
|
561
|
+
console.log(chalk.dim(' Please select a specific model, not a provider header'));
|
|
562
|
+
} else {
|
|
563
|
+
console.log(chalk.dim(' Model selection cancelled'));
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
// Direct model switch
|
|
567
|
+
const success = await this.switchModel(arg);
|
|
568
|
+
if (success) {
|
|
569
|
+
console.log(chalk.green(` ✓ Switched to: ${arg}`));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private async handleOllamaCommand(arg: string): Promise<void> {
|
|
575
|
+
const creds = this.authManager.getCredentials('ollama');
|
|
576
|
+
const baseUrl = creds?.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434';
|
|
577
|
+
|
|
578
|
+
console.log(chalk.hex('#7C3AED')('\n Ollama Status:'));
|
|
579
|
+
console.log(chalk.gray(` Host: ${baseUrl}`));
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
const response = await fetch(`${baseUrl}/api/tags`);
|
|
583
|
+
if (response.ok) {
|
|
584
|
+
const data = await response.json() as { models: Array<{ name: string; size: number }> };
|
|
585
|
+
console.log(chalk.green(' Status: Running'));
|
|
586
|
+
console.log(chalk.gray(` Models: ${data.models?.length || 0} installed`));
|
|
587
|
+
if (data.models?.length > 0) {
|
|
588
|
+
data.models.slice(0, 5).forEach((m: any) => {
|
|
589
|
+
console.log(chalk.gray(` - ${m.name}`));
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
console.log(chalk.yellow(' Status: Not responding'));
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
console.log(chalk.red(' Status: Not running'));
|
|
597
|
+
console.log(chalk.gray(' Start with: ollama serve'));
|
|
598
|
+
}
|
|
599
|
+
console.log('');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Switch to a different model, potentially across providers.
|
|
604
|
+
* Accepts "provider/model" or bare "model" format.
|
|
605
|
+
* Persists selection to global config.
|
|
606
|
+
*/
|
|
607
|
+
private async switchModel(modelId: string): Promise<boolean> {
|
|
608
|
+
try {
|
|
609
|
+
// Parse provider/model format (e.g., "ollama/gemma3:4b")
|
|
610
|
+
let providerName: string;
|
|
611
|
+
let actualModelId: string;
|
|
612
|
+
|
|
613
|
+
if (modelId.includes('/')) {
|
|
614
|
+
const idx = modelId.indexOf('/');
|
|
615
|
+
providerName = modelId.substring(0, idx);
|
|
616
|
+
actualModelId = modelId.substring(idx + 1);
|
|
617
|
+
} else {
|
|
618
|
+
// Bare model name — look up in config to find provider
|
|
619
|
+
const modelConfig = this.configManager.getModelConfig(modelId);
|
|
620
|
+
if (!modelConfig) {
|
|
621
|
+
// Might be an Ollama model
|
|
622
|
+
if (this.authManager.hasCredentials('ollama') || process.env.OLLAMA_HOST) {
|
|
623
|
+
providerName = 'ollama';
|
|
624
|
+
actualModelId = modelId;
|
|
625
|
+
} else {
|
|
626
|
+
console.log(chalk.red(` ✗ Model "${modelId}" not found in config`));
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
const config = this.configManager.getConfig();
|
|
631
|
+
for (const [name, p] of Object.entries(config.models.providers)) {
|
|
632
|
+
if (p === modelConfig.provider || p.models === modelConfig.provider.models) {
|
|
633
|
+
providerName = name;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
actualModelId = modelId;
|
|
638
|
+
if (!providerName) {
|
|
639
|
+
console.log(chalk.red(` ✗ Cannot determine provider for "${modelId}"`));
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Get provider config
|
|
646
|
+
const config = this.configManager.getConfig();
|
|
647
|
+
const providerConfig = config.models.providers[providerName];
|
|
648
|
+
if (!providerConfig) {
|
|
649
|
+
console.log(chalk.red(` ✗ Unknown provider: "${providerName}"`));
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const providerType = providerConfig.type;
|
|
654
|
+
const creds = this.authManager.getCredentials(providerName);
|
|
655
|
+
|
|
656
|
+
// For non-Ollama providers, require API key
|
|
657
|
+
const isOllamaType = providerType === 'ollama' || providerName === 'ollama' || providerName === 'ollama-cloud';
|
|
658
|
+
if (!isOllamaType && !creds?.apiKey) {
|
|
659
|
+
console.log(chalk.yellow(` ⚠ No API key found for "${providerName}"`));
|
|
660
|
+
console.log(chalk.gray(` Set it with: nova auth set ${providerName}`));
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Create new ModelClient with correct provider
|
|
665
|
+
this.modelClient = new ModelClient({
|
|
666
|
+
provider: providerType as any,
|
|
667
|
+
apiKey: creds?.apiKey,
|
|
668
|
+
baseUrl: creds?.baseUrl || providerConfig.baseUrl,
|
|
669
|
+
model: actualModelId,
|
|
670
|
+
maxTokens: this.config.core.maxTokens,
|
|
671
|
+
temperature: this.config.core.temperature,
|
|
672
|
+
organizationId: creds?.organizationId,
|
|
673
|
+
codingPlanPlatform: providerConfig.codingPlanPlatform as any,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Update state
|
|
677
|
+
this.state.model = `${providerName}/${actualModelId}`;
|
|
678
|
+
|
|
679
|
+
// Persist to global config
|
|
680
|
+
config.core.defaultModel = `${providerName}/${actualModelId}`;
|
|
681
|
+
await this.configManager.save(config);
|
|
682
|
+
|
|
683
|
+
return true;
|
|
684
|
+
} catch (err) {
|
|
685
|
+
console.log(chalk.red(` ✗ Error switching model: ${(err as Error).message}`));
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private printStatus(): void {
|
|
691
|
+
console.log(chalk.hex('#7C3AED')('\n Session Status:'));
|
|
692
|
+
console.log(chalk.gray(` Session: ${this.sessionId?.slice(0, 8) || 'none'}`));
|
|
693
|
+
console.log(chalk.gray(` Model: ${this.state.model}`));
|
|
694
|
+
console.log(chalk.gray(` Mode: ${this.state.mode}`));
|
|
695
|
+
console.log(chalk.gray(` Context: ${this.state.contextUsage}%`));
|
|
696
|
+
console.log(chalk.gray(` Messages: ${this.state.messages.length}`));
|
|
697
|
+
console.log('');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private printMcpStatus(): void {
|
|
701
|
+
if (!this.mcpManager) {
|
|
702
|
+
console.log(chalk.gray(' MCP not initialized'));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const statuses = this.mcpManager.listServers();
|
|
707
|
+
if (statuses.length === 0) {
|
|
708
|
+
console.log(chalk.gray(' No MCP servers configured'));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
console.log(chalk.hex('#7C3AED')('\n MCP Servers:'));
|
|
713
|
+
for (const s of statuses) {
|
|
714
|
+
const icon = s.connected ? chalk.green('✓') : chalk.red('✗');
|
|
715
|
+
console.log(chalk.gray(` ${icon} ${s.name}: ${s.connected ? 'connected' : 'disconnected'}`));
|
|
716
|
+
}
|
|
717
|
+
console.log('');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async handleMcpCommand(arg: string): Promise<void> {
|
|
721
|
+
if (!this.mcpManager) {
|
|
722
|
+
console.log(chalk.gray(' MCP not initialized'));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const statuses = this.mcpManager.listServers();
|
|
727
|
+
|
|
728
|
+
if (statuses.length === 0) {
|
|
729
|
+
console.log(chalk.gray(' No MCP servers configured'));
|
|
730
|
+
console.log(chalk.dim(' Add servers to ~/.nova/config.yaml under "mcp:"'));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (!arg) {
|
|
735
|
+
// Show status (non-interactive for now)
|
|
736
|
+
this.printMcpStatus();
|
|
737
|
+
} else {
|
|
738
|
+
// Specific server action requested
|
|
739
|
+
console.log(chalk.green(` MCP server: ${arg}`));
|
|
740
|
+
console.log(chalk.dim(' Note: MCP server management not yet implemented in interactive mode'));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private printSkillsStatus(): void {
|
|
745
|
+
if (!this.skillRegistry) {
|
|
746
|
+
console.log(chalk.gray(' Skills not initialized'));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// We'd need async for this, but for now just show a message
|
|
751
|
+
console.log(chalk.hex('#7C3AED')('\n Skills:'));
|
|
752
|
+
console.log(chalk.gray(' Use /skills <name> to inject a skill'));
|
|
753
|
+
console.log('');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private async handleSkillsCommand(arg: string): Promise<void> {
|
|
757
|
+
if (!this.skillRegistry) {
|
|
758
|
+
console.log(chalk.gray(' Skills not initialized'));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (!arg) {
|
|
763
|
+
// Interactive skill selection
|
|
764
|
+
try {
|
|
765
|
+
const skills = await this.skillRegistry.list();
|
|
766
|
+
if (skills.length === 0) {
|
|
767
|
+
console.log(chalk.dim(' No skills installed. Use "nova skills install" to install skills.'));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const skillItems = skills.map(skill => ({
|
|
772
|
+
name: skill.metadata.name,
|
|
773
|
+
description: skill.metadata.description,
|
|
774
|
+
}));
|
|
775
|
+
|
|
776
|
+
const selectedSkill = await selectSkillInteractive(skillItems);
|
|
777
|
+
|
|
778
|
+
if (selectedSkill) {
|
|
779
|
+
console.log(chalk.green(` ✓ Selected skill: ${selectedSkill}`));
|
|
780
|
+
console.log(chalk.dim(' Note: Skill injection not yet implemented in interactive mode'));
|
|
781
|
+
} else {
|
|
782
|
+
console.log(chalk.dim(' Skill selection cancelled'));
|
|
783
|
+
}
|
|
784
|
+
} catch (error) {
|
|
785
|
+
console.log(chalk.red(` Error loading skills: ${(error as Error).message}`));
|
|
786
|
+
}
|
|
787
|
+
} else {
|
|
788
|
+
// Specific skill requested
|
|
789
|
+
console.log(chalk.green(` ✓ Selected skill: ${arg}`));
|
|
790
|
+
console.log(chalk.dim(' Note: Skill injection not yet implemented in interactive mode'));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ========================================================================
|
|
795
|
+
// Shell Command Handler
|
|
796
|
+
// ========================================================================
|
|
797
|
+
|
|
798
|
+
private async handleShellCommand(cmd: string): Promise<void> {
|
|
799
|
+
if (!cmd) {
|
|
800
|
+
console.log(chalk.gray(' Usage: !<command>'));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
console.log(chalk.gray(` $ ${cmd}`));
|
|
805
|
+
const startTime = Date.now();
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
const isWin = process.platform === 'win32';
|
|
809
|
+
const shell = isWin ? 'powershell.exe' : '/bin/sh';
|
|
810
|
+
const shellArgs = isWin ? ['-Command', cmd] : ['-c', cmd];
|
|
811
|
+
|
|
812
|
+
await new Promise<void>((resolve, reject) => {
|
|
813
|
+
const child = spawn(shell, shellArgs, {
|
|
814
|
+
cwd: this.cwd,
|
|
815
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(chunk.toString()));
|
|
819
|
+
child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk.toString()));
|
|
820
|
+
|
|
821
|
+
child.on('close', (code) => {
|
|
822
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
823
|
+
if (code === 0) {
|
|
824
|
+
console.log(chalk.green(` ✓ exit 0`) + chalk.dim(` (${duration}s)`));
|
|
825
|
+
} else {
|
|
826
|
+
console.log(chalk.red(` ✗ exit ${code}`) + chalk.dim(` (${duration}s)`));
|
|
827
|
+
}
|
|
828
|
+
resolve();
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
child.on('error', reject);
|
|
832
|
+
});
|
|
833
|
+
} catch (err) {
|
|
834
|
+
console.log(chalk.red(` Error: ${(err as Error).message}`));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ========================================================================
|
|
839
|
+
// Input Processing
|
|
840
|
+
// ========================================================================
|
|
841
|
+
|
|
842
|
+
private async processInput(input: string): Promise<void> {
|
|
843
|
+
if (!this.sessionId) return;
|
|
844
|
+
|
|
845
|
+
// Expand @file references
|
|
846
|
+
const expandedInput = await this.expandAtReferences(input);
|
|
847
|
+
|
|
848
|
+
// Show user message
|
|
849
|
+
console.log('');
|
|
850
|
+
console.log(chalk.dim(' • ') + chalk.white(input));
|
|
851
|
+
console.log(chalk.dim(' ' + BOX.h.repeat(20)));
|
|
852
|
+
|
|
853
|
+
// Add to messages
|
|
854
|
+
this.state.messages.push({
|
|
855
|
+
id: Date.now().toString(),
|
|
856
|
+
role: 'user',
|
|
857
|
+
content: input,
|
|
858
|
+
timestamp: new Date(),
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Get mode prefix
|
|
862
|
+
const modePrefix = this.getModePrefix();
|
|
863
|
+
const fullInput = modePrefix ? `${modePrefix}\n\n${expandedInput}` : expandedInput;
|
|
864
|
+
|
|
865
|
+
// Build system prompt
|
|
866
|
+
const systemPrompt = buildSystemPrompt({
|
|
867
|
+
workingDirectory: this.cwd,
|
|
868
|
+
model: this.modelClient.getModel(),
|
|
869
|
+
approvalMode: this.getEffectiveApprovalMode(),
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// Create agent loop
|
|
873
|
+
this.currentLoop = new AgentLoop({
|
|
874
|
+
modelClient: this.modelClient,
|
|
875
|
+
sessionManager: this.sessionManager,
|
|
876
|
+
toolRegistry: this.toolRegistry,
|
|
877
|
+
systemPrompt,
|
|
878
|
+
contextCompressor: this.contextCompressor,
|
|
879
|
+
maxContextTokens: (this.config.core.maxTokens || 16384) * 8,
|
|
880
|
+
|
|
881
|
+
onTextDelta: (text: string) => {
|
|
882
|
+
this.currentText += text;
|
|
883
|
+
process.stdout.write(text);
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
onToolStart: (name: string, toolCallId: string) => {
|
|
887
|
+
this.pendingToolCalls.set(toolCallId, { name, startTime: Date.now() });
|
|
888
|
+
console.log(chalk.cyan(`\n ⚡ ${name}`));
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
onToolComplete: (name: string, toolCallId: string, result: any) => {
|
|
892
|
+
const info = this.pendingToolCalls.get(toolCallId);
|
|
893
|
+
if (info) {
|
|
894
|
+
const duration = Date.now() - info.startTime;
|
|
895
|
+
const icon = result.isError ? chalk.red('✗') : chalk.green('✓');
|
|
896
|
+
console.log(` ${icon} ${chalk.dim(`(${duration}ms)`)}`);
|
|
897
|
+
this.pendingToolCalls.delete(toolCallId);
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
onThinkingStart: () => {
|
|
902
|
+
if (this.state.showThinking) {
|
|
903
|
+
this.thinkingRenderer.start();
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
onThinkingDelta: (delta: string) => {
|
|
908
|
+
if (this.state.showThinking) {
|
|
909
|
+
this.thinkingRenderer.append(delta);
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
onThinkingEnd: () => {
|
|
914
|
+
if (this.state.showThinking) {
|
|
915
|
+
this.thinkingRenderer.complete();
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
|
|
919
|
+
onApprovalRequired: this.handleApproval.bind(this),
|
|
920
|
+
|
|
921
|
+
onTurnStart: (turn: number) => {
|
|
922
|
+
if (turn > 1) {
|
|
923
|
+
console.log(chalk.dim(`\n ─── turn ${turn} ───`));
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
onTurnEnd: () => {
|
|
928
|
+
// Nothing
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
onContextCompress: (orig: number, result: number, action: string) => {
|
|
932
|
+
console.log(chalk.dim(`\n → context: ${orig} → ${result} tokens (${action})`));
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
const result = await this.currentLoop.runStream(this.sessionId, fullInput);
|
|
938
|
+
|
|
939
|
+
// Add assistant message
|
|
940
|
+
this.state.messages.push({
|
|
941
|
+
id: (Date.now() + 1).toString(),
|
|
942
|
+
role: 'assistant',
|
|
943
|
+
content: this.currentText,
|
|
944
|
+
timestamp: new Date(),
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
// Update context usage
|
|
948
|
+
const totalTokens = result.totalInputTokens + result.totalOutputTokens;
|
|
949
|
+
this.state.contextUsage = Math.min(100, Math.round((totalTokens / 128000) * 100));
|
|
950
|
+
|
|
951
|
+
// Show summary
|
|
952
|
+
console.log('');
|
|
953
|
+
console.log(chalk.dim(` ✓ ${result.turnsCompleted} turns · ${totalTokens.toLocaleString()} tokens`));
|
|
954
|
+
|
|
955
|
+
// Persist session
|
|
956
|
+
this.sessionManager.persist(this.sessionId);
|
|
957
|
+
|
|
958
|
+
} catch (err) {
|
|
959
|
+
if ((err as any).name !== 'CancelledError') {
|
|
960
|
+
console.log(chalk.red(`\n Error: ${(err as Error).message}`));
|
|
961
|
+
}
|
|
962
|
+
} finally {
|
|
963
|
+
this.currentLoop = null;
|
|
964
|
+
this.currentText = '';
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ========================================================================
|
|
969
|
+
// @ File Reference Expansion
|
|
970
|
+
// ========================================================================
|
|
971
|
+
|
|
972
|
+
private async expandAtReferences(input: string): Promise<string> {
|
|
973
|
+
const atPattern = /@([\w./\-\\]+)/g;
|
|
974
|
+
const matches = [...input.matchAll(atPattern)];
|
|
975
|
+
if (matches.length === 0) return input;
|
|
976
|
+
|
|
977
|
+
const injections: string[] = [];
|
|
978
|
+
|
|
979
|
+
for (const match of matches) {
|
|
980
|
+
const refPath = match[1];
|
|
981
|
+
if (!refPath) continue;
|
|
982
|
+
|
|
983
|
+
const absPath = path.isAbsolute(refPath)
|
|
984
|
+
? refPath
|
|
985
|
+
: path.resolve(this.cwd, refPath);
|
|
986
|
+
|
|
987
|
+
try {
|
|
988
|
+
if (!fs.existsSync(absPath)) {
|
|
989
|
+
injections.push(`[@${refPath}: not found]`);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const stat = fs.statSync(absPath);
|
|
994
|
+
if (stat.isDirectory()) {
|
|
995
|
+
const files = fs.readdirSync(absPath).slice(0, 20);
|
|
996
|
+
injections.push(`\n\`\`\`\n# Directory: ${refPath}\n${files.join('\n')}\n\`\`\`\n`);
|
|
997
|
+
} else {
|
|
998
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
999
|
+
const ext = path.extname(refPath).slice(1) || 'txt';
|
|
1000
|
+
injections.push(`\n\`\`\`${ext}\n# ${refPath}\n${content}\n\`\`\`\n`);
|
|
1001
|
+
}
|
|
1002
|
+
console.log(chalk.dim(` @ ${refPath}`));
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
injections.push(`[@${refPath}: error]`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return input + '\n' + injections.join('\n');
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ========================================================================
|
|
1012
|
+
// Approval Handler
|
|
1013
|
+
// ========================================================================
|
|
1014
|
+
|
|
1015
|
+
private async handleApproval(request: ApprovalRequest): Promise<ApprovalResponse> {
|
|
1016
|
+
const mode = this.getEffectiveApprovalMode();
|
|
1017
|
+
|
|
1018
|
+
if (mode === 'yolo' || mode === 'accepting_edits') {
|
|
1019
|
+
return { requestId: request.id, approved: true };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
console.log('');
|
|
1023
|
+
console.log(chalk.yellow.bold(' ⚠ Approval Required'));
|
|
1024
|
+
console.log(chalk.gray(` Tool: ${request.toolName}`));
|
|
1025
|
+
console.log(chalk.gray(` Risk: ${request.risk}`));
|
|
1026
|
+
console.log('');
|
|
1027
|
+
|
|
1028
|
+
// For now, auto-approve in non-interactive mode
|
|
1029
|
+
// In a full implementation, this would use a ConfirmDialog
|
|
1030
|
+
return { requestId: request.id, approved: true };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ========================================================================
|
|
1034
|
+
// Helpers
|
|
1035
|
+
// ========================================================================
|
|
1036
|
+
|
|
1037
|
+
private getModePrefix(): string {
|
|
1038
|
+
switch (this.state.mode) {
|
|
1039
|
+
case 'plan':
|
|
1040
|
+
return '[PLAN MODE] First analyze and create a step-by-step plan. Wait for confirmation before executing.';
|
|
1041
|
+
case 'ask':
|
|
1042
|
+
return '[ASK MODE] Only answer questions. Do NOT modify files or execute commands.';
|
|
1043
|
+
default:
|
|
1044
|
+
return '';
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private getEffectiveApprovalMode(): string {
|
|
1049
|
+
switch (this.state.mode) {
|
|
1050
|
+
case 'auto': return 'yolo';
|
|
1051
|
+
case 'plan': return 'plan';
|
|
1052
|
+
case 'ask': return 'plan';
|
|
1053
|
+
default: return 'default';
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|