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.

Files changed (192) hide show
  1. package/README.md +358 -0
  2. package/bin/nova +38 -0
  3. package/bin/nova.js +12 -0
  4. package/package.json +67 -0
  5. package/src/cli/commands/SmartCompletion.ts +458 -0
  6. package/src/cli/index.ts +5 -0
  7. package/src/cli/startup/IFlowRepl.ts +212 -0
  8. package/src/cli/startup/InkBasedRepl.ts +1056 -0
  9. package/src/cli/startup/InteractiveRepl.ts +2833 -0
  10. package/src/cli/startup/NovaApp.ts +1861 -0
  11. package/src/cli/startup/index.ts +4 -0
  12. package/src/cli/startup/parseArgs.ts +293 -0
  13. package/src/cli/test-modules.ts +27 -0
  14. package/src/cli/ui/IFlowDropdown.ts +425 -0
  15. package/src/cli/ui/ModernReplUI.ts +276 -0
  16. package/src/cli/ui/SimpleSelector2.ts +215 -0
  17. package/src/cli/ui/components/ConfirmDialog.ts +176 -0
  18. package/src/cli/ui/components/ErrorPanel.ts +364 -0
  19. package/src/cli/ui/components/InkAppRunner.tsx +67 -0
  20. package/src/cli/ui/components/InkComponents.tsx +613 -0
  21. package/src/cli/ui/components/NovaInkApp.tsx +312 -0
  22. package/src/cli/ui/components/ProgressBar.ts +177 -0
  23. package/src/cli/ui/components/ProgressIndicator.ts +298 -0
  24. package/src/cli/ui/components/QuickActions.ts +396 -0
  25. package/src/cli/ui/components/SimpleErrorPanel.ts +231 -0
  26. package/src/cli/ui/components/StatusBar.ts +194 -0
  27. package/src/cli/ui/components/ThinkingBlockRenderer.ts +401 -0
  28. package/src/cli/ui/components/index.ts +27 -0
  29. package/src/cli/ui/ink-prototype.tsx +347 -0
  30. package/src/cli/utils/CliUI.ts +336 -0
  31. package/src/cli/utils/CompletionHelper.ts +388 -0
  32. package/src/cli/utils/EnhancedCompleter.test.ts +226 -0
  33. package/src/cli/utils/EnhancedCompleter.ts +513 -0
  34. package/src/cli/utils/ErrorEnhancer.ts +429 -0
  35. package/src/cli/utils/OutputFormatter.ts +193 -0
  36. package/src/cli/utils/index.ts +9 -0
  37. package/src/core/agents/AgentOrchestrator.ts +515 -0
  38. package/src/core/agents/index.ts +17 -0
  39. package/src/core/audit/AuditLogger.ts +509 -0
  40. package/src/core/audit/index.ts +11 -0
  41. package/src/core/auth/AuthManager.d.ts.map +1 -0
  42. package/src/core/auth/AuthManager.ts +138 -0
  43. package/src/core/auth/index.d.ts.map +1 -0
  44. package/src/core/auth/index.ts +2 -0
  45. package/src/core/config/ConfigManager.d.ts.map +1 -0
  46. package/src/core/config/ConfigManager.test.ts +183 -0
  47. package/src/core/config/ConfigManager.ts +1219 -0
  48. package/src/core/config/index.d.ts.map +1 -0
  49. package/src/core/config/index.ts +1 -0
  50. package/src/core/context/ContextBuilder.d.ts.map +1 -0
  51. package/src/core/context/ContextBuilder.ts +171 -0
  52. package/src/core/context/ContextCompressor.d.ts.map +1 -0
  53. package/src/core/context/ContextCompressor.ts +642 -0
  54. package/src/core/context/LayeredMemoryManager.ts +657 -0
  55. package/src/core/context/MemoryDiscovery.d.ts.map +1 -0
  56. package/src/core/context/MemoryDiscovery.ts +175 -0
  57. package/src/core/context/defaultSystemPrompt.d.ts.map +1 -0
  58. package/src/core/context/defaultSystemPrompt.ts +35 -0
  59. package/src/core/context/index.d.ts.map +1 -0
  60. package/src/core/context/index.ts +22 -0
  61. package/src/core/extensions/SkillGenerator.ts +421 -0
  62. package/src/core/extensions/SkillInstaller.d.ts.map +1 -0
  63. package/src/core/extensions/SkillInstaller.ts +257 -0
  64. package/src/core/extensions/SkillRegistry.d.ts.map +1 -0
  65. package/src/core/extensions/SkillRegistry.ts +361 -0
  66. package/src/core/extensions/SkillValidator.ts +525 -0
  67. package/src/core/extensions/index.ts +15 -0
  68. package/src/core/index.d.ts.map +1 -0
  69. package/src/core/index.ts +42 -0
  70. package/src/core/mcp/McpManager.d.ts.map +1 -0
  71. package/src/core/mcp/McpManager.ts +632 -0
  72. package/src/core/mcp/index.d.ts.map +1 -0
  73. package/src/core/mcp/index.ts +2 -0
  74. package/src/core/model/ModelClient.d.ts.map +1 -0
  75. package/src/core/model/ModelClient.ts +217 -0
  76. package/src/core/model/ModelConnectionTester.ts +363 -0
  77. package/src/core/model/ModelValidator.ts +348 -0
  78. package/src/core/model/index.d.ts.map +1 -0
  79. package/src/core/model/index.ts +6 -0
  80. package/src/core/model/providers/AnthropicProvider.d.ts.map +1 -0
  81. package/src/core/model/providers/AnthropicProvider.ts +279 -0
  82. package/src/core/model/providers/CodingPlanProvider.d.ts.map +1 -0
  83. package/src/core/model/providers/CodingPlanProvider.ts +210 -0
  84. package/src/core/model/providers/OllamaCloudProvider.d.ts.map +1 -0
  85. package/src/core/model/providers/OllamaCloudProvider.ts +405 -0
  86. package/src/core/model/providers/OllamaManager.d.ts.map +1 -0
  87. package/src/core/model/providers/OllamaManager.ts +201 -0
  88. package/src/core/model/providers/OllamaProvider.d.ts.map +1 -0
  89. package/src/core/model/providers/OllamaProvider.ts +73 -0
  90. package/src/core/model/providers/OpenAICompatibleProvider.d.ts.map +1 -0
  91. package/src/core/model/providers/OpenAICompatibleProvider.ts +327 -0
  92. package/src/core/model/providers/OpenAIProvider.d.ts.map +1 -0
  93. package/src/core/model/providers/OpenAIProvider.ts +29 -0
  94. package/src/core/model/providers/index.d.ts.map +1 -0
  95. package/src/core/model/providers/index.ts +12 -0
  96. package/src/core/model/types.d.ts.map +1 -0
  97. package/src/core/model/types.ts +77 -0
  98. package/src/core/security/ApprovalManager.d.ts.map +1 -0
  99. package/src/core/security/ApprovalManager.ts +174 -0
  100. package/src/core/security/FileFilter.d.ts.map +1 -0
  101. package/src/core/security/FileFilter.ts +141 -0
  102. package/src/core/security/HookExecutor.d.ts.map +1 -0
  103. package/src/core/security/HookExecutor.ts +178 -0
  104. package/src/core/security/SandboxExecutor.ts +447 -0
  105. package/src/core/security/index.d.ts.map +1 -0
  106. package/src/core/security/index.ts +8 -0
  107. package/src/core/session/AgentLoop.d.ts.map +1 -0
  108. package/src/core/session/AgentLoop.ts +501 -0
  109. package/src/core/session/SessionManager.d.ts.map +1 -0
  110. package/src/core/session/SessionManager.test.ts +183 -0
  111. package/src/core/session/SessionManager.ts +460 -0
  112. package/src/core/session/index.d.ts.map +1 -0
  113. package/src/core/session/index.ts +3 -0
  114. package/src/core/telemetry/Telemetry.d.ts.map +1 -0
  115. package/src/core/telemetry/Telemetry.ts +90 -0
  116. package/src/core/telemetry/TelemetryService.ts +531 -0
  117. package/src/core/telemetry/index.d.ts.map +1 -0
  118. package/src/core/telemetry/index.ts +12 -0
  119. package/src/core/testing/AutoFixer.ts +385 -0
  120. package/src/core/testing/ErrorAnalyzer.ts +499 -0
  121. package/src/core/testing/TestRunner.ts +265 -0
  122. package/src/core/testing/agent-cli-tests.ts +538 -0
  123. package/src/core/testing/index.ts +11 -0
  124. package/src/core/tools/ToolRegistry.d.ts.map +1 -0
  125. package/src/core/tools/ToolRegistry.test.ts +206 -0
  126. package/src/core/tools/ToolRegistry.ts +260 -0
  127. package/src/core/tools/impl/EditFileTool.d.ts.map +1 -0
  128. package/src/core/tools/impl/EditFileTool.ts +97 -0
  129. package/src/core/tools/impl/ListDirectoryTool.d.ts.map +1 -0
  130. package/src/core/tools/impl/ListDirectoryTool.ts +142 -0
  131. package/src/core/tools/impl/MemoryTool.d.ts.map +1 -0
  132. package/src/core/tools/impl/MemoryTool.ts +102 -0
  133. package/src/core/tools/impl/ReadFileTool.d.ts.map +1 -0
  134. package/src/core/tools/impl/ReadFileTool.ts +58 -0
  135. package/src/core/tools/impl/SearchContentTool.d.ts.map +1 -0
  136. package/src/core/tools/impl/SearchContentTool.ts +94 -0
  137. package/src/core/tools/impl/SearchFileTool.d.ts.map +1 -0
  138. package/src/core/tools/impl/SearchFileTool.ts +61 -0
  139. package/src/core/tools/impl/ShellTool.d.ts.map +1 -0
  140. package/src/core/tools/impl/ShellTool.ts +118 -0
  141. package/src/core/tools/impl/TaskTool.d.ts.map +1 -0
  142. package/src/core/tools/impl/TaskTool.ts +207 -0
  143. package/src/core/tools/impl/TodoTool.d.ts.map +1 -0
  144. package/src/core/tools/impl/TodoTool.ts +122 -0
  145. package/src/core/tools/impl/WebFetchTool.d.ts.map +1 -0
  146. package/src/core/tools/impl/WebFetchTool.ts +103 -0
  147. package/src/core/tools/impl/WebSearchTool.d.ts.map +1 -0
  148. package/src/core/tools/impl/WebSearchTool.ts +89 -0
  149. package/src/core/tools/impl/WriteFileTool.d.ts.map +1 -0
  150. package/src/core/tools/impl/WriteFileTool.ts +49 -0
  151. package/src/core/tools/impl/index.d.ts.map +1 -0
  152. package/src/core/tools/impl/index.ts +16 -0
  153. package/src/core/tools/index.d.ts.map +1 -0
  154. package/src/core/tools/index.ts +7 -0
  155. package/src/core/tools/schemas/execution.d.ts.map +1 -0
  156. package/src/core/tools/schemas/execution.ts +42 -0
  157. package/src/core/tools/schemas/file.d.ts.map +1 -0
  158. package/src/core/tools/schemas/file.ts +119 -0
  159. package/src/core/tools/schemas/index.d.ts.map +1 -0
  160. package/src/core/tools/schemas/index.ts +11 -0
  161. package/src/core/tools/schemas/memory.d.ts.map +1 -0
  162. package/src/core/tools/schemas/memory.ts +52 -0
  163. package/src/core/tools/schemas/orchestration.d.ts.map +1 -0
  164. package/src/core/tools/schemas/orchestration.ts +44 -0
  165. package/src/core/tools/schemas/search.d.ts.map +1 -0
  166. package/src/core/tools/schemas/search.ts +112 -0
  167. package/src/core/tools/schemas/todo.d.ts.map +1 -0
  168. package/src/core/tools/schemas/todo.ts +32 -0
  169. package/src/core/tools/schemas/web.d.ts.map +1 -0
  170. package/src/core/tools/schemas/web.ts +86 -0
  171. package/src/core/types/config.d.ts.map +1 -0
  172. package/src/core/types/config.ts +200 -0
  173. package/src/core/types/errors.d.ts.map +1 -0
  174. package/src/core/types/errors.ts +204 -0
  175. package/src/core/types/index.d.ts.map +1 -0
  176. package/src/core/types/index.ts +8 -0
  177. package/src/core/types/session.d.ts.map +1 -0
  178. package/src/core/types/session.ts +216 -0
  179. package/src/core/types/tools.d.ts.map +1 -0
  180. package/src/core/types/tools.ts +157 -0
  181. package/src/core/utils/CheckpointManager.d.ts.map +1 -0
  182. package/src/core/utils/CheckpointManager.ts +327 -0
  183. package/src/core/utils/Logger.d.ts.map +1 -0
  184. package/src/core/utils/Logger.ts +98 -0
  185. package/src/core/utils/RetryManager.ts +471 -0
  186. package/src/core/utils/TokenCounter.d.ts.map +1 -0
  187. package/src/core/utils/TokenCounter.ts +414 -0
  188. package/src/core/utils/VectorMemoryStore.ts +440 -0
  189. package/src/core/utils/helpers.d.ts.map +1 -0
  190. package/src/core/utils/helpers.ts +89 -0
  191. package/src/core/utils/index.d.ts.map +1 -0
  192. 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
+ }