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,2833 @@
1
+ // ============================================================================
2
+ // InteractiveRepl - Interactive read-eval-print loop with rich UX v3
3
+ // Features: multi-line input, @file references, !shell, /init, /memory,
4
+ // /history, session auto-persist, /model switch
5
+ // ============================================================================
6
+
7
+ import * as readline from 'node:readline';
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as os from 'node:os';
11
+ import { execSync, spawn } from 'node:child_process';
12
+ import chalk from 'chalk';
13
+ import type { NovaConfig } from '../core/types/config.js';
14
+ import type { SessionId, ApprovalRequest, ApprovalResponse } from '../core/types/session.js';
15
+ import { AgentLoop } from '../core/session/AgentLoop.js';
16
+ import { ModelClient } from '../core/model/ModelClient.js';
17
+ import { SessionManager } from '../core/session/SessionManager.js';
18
+ import { ToolRegistry } from '../core/tools/ToolRegistry.js';
19
+ import { ApprovalManager } from '../core/security/ApprovalManager.js';
20
+ import { buildSystemPrompt } from '../core/context/defaultSystemPrompt.js';
21
+ import { ThinkingBlockRenderer } from '../ui/components/ThinkingBlockRenderer.js';
22
+ import type { McpManager, McpServerStatus } from '../core/mcp/McpManager.js';
23
+ import type { SkillRegistry, SkillDefinition } from '../core/extensions/SkillRegistry.js';
24
+ import type { ConfigManager } from '../core/config/ConfigManager.js';
25
+ import type { AuthManager } from '../core/auth/AuthManager.js';
26
+ import { OllamaManager } from '../core/model/providers/OllamaManager.js';
27
+ import { CompletionHelper } from '../utils/CompletionHelper.js';
28
+ import { EnhancedCompleter, type CompletionCandidate } from '../utils/EnhancedCompleter.js';
29
+
30
+ // ============================================================================
31
+ // Types
32
+ // ============================================================================
33
+
34
+ export interface ReplOptions {
35
+ modelClient: ModelClient;
36
+ sessionManager: SessionManager;
37
+ toolRegistry: ToolRegistry;
38
+ approvalManager: ApprovalManager;
39
+ authManager?: AuthManager;
40
+ config: NovaConfig;
41
+ configManager: ConfigManager;
42
+ cwd: string;
43
+ contextCompressor?: any;
44
+ mcpManager?: McpManager;
45
+ skillRegistry?: SkillRegistry;
46
+ /** If set, restore this session on startup instead of creating a new one */
47
+ restoreSessionId?: string;
48
+ }
49
+
50
+ /** Interaction mode for the REPL */
51
+ type InteractionMode = 'auto' | 'plan' | 'ask';
52
+
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ const MODE_LABELS: Record<InteractionMode, { label: string; color: any; description: string; approvalMode: string }> = {
55
+ auto: { label: 'AUTO', color: chalk.green.bold, description: 'Full autonomous - no approval needed', approvalMode: 'yolo' },
56
+ plan: { label: 'PLAN', color: chalk.yellow.bold, description: 'Plan first, then confirm each action', approvalMode: 'plan' },
57
+ ask: { label: 'ASK', color: chalk.cyan.bold, description: 'Answer only, no file changes', approvalMode: 'plan' },
58
+ };
59
+
60
+ const MODES: InteractionMode[] = ['auto', 'plan', 'ask'];
61
+
62
+ /** State for rendering tool calls */
63
+ interface ToolCallState {
64
+ name: string;
65
+ toolCallId: string;
66
+ startTime: number;
67
+ input: string;
68
+ result: string;
69
+ isError: boolean;
70
+ isComplete: boolean;
71
+ lineIndex: number;
72
+ }
73
+
74
+ // ============================================================================
75
+ // Box drawing chars & color palette (enhanced UI)
76
+ // ============================================================================
77
+
78
+ const BOX = {
79
+ tl: '╭', tr: '╮', bl: '╰', br: '╯',
80
+ h: '─', v: '│',
81
+ ht: '├', htr: '┤', cross: '┼',
82
+ arrow: '›', bullet: '•', check: '✓', crossX: '✗', dot: '·',
83
+ diamond: '◆', star: '★', circle: '○', circleFull: '●',
84
+ spinner: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'],
85
+ hThick: '━', vThick: '┃',
86
+ arrowRight: '→', arrowLeft: '←', arrowUp: '↑', arrowDown: '↓',
87
+ };
88
+
89
+ const C = {
90
+ brand: chalk.hex('#7C3AED').bold,
91
+ brandLight: chalk.hex('#A78BFA'),
92
+ brandDim: chalk.hex('#7C3AED').dim,
93
+ success: chalk.hex('#10B981'),
94
+ successDim: chalk.hex('#10B981').dim,
95
+ warning: chalk.hex('#F59E0B'),
96
+ warningDim: chalk.hex('#F59E0B').dim,
97
+ error: chalk.hex('#EF4444'),
98
+ errorDim: chalk.hex('#EF4444').dim,
99
+ info: chalk.hex('#3B82F6'),
100
+ infoDim: chalk.hex('#3B82F6').dim,
101
+ primary: chalk.white,
102
+ muted: chalk.gray,
103
+ dim: chalk.hex('#6B7280'),
104
+ toolName: chalk.hex('#22D3EE'),
105
+ toolOk: chalk.hex('#6EE7B7'),
106
+ toolErr: chalk.hex('#FCA5A5'),
107
+ turnLine: chalk.hex('#374151').dim,
108
+ accent: chalk.hex('#F472B6'),
109
+ subtle: chalk.hex('#4B5563'),
110
+ };
111
+
112
+ // ============================================================================
113
+ // InteractiveRepl
114
+ // ============================================================================
115
+
116
+ export class InteractiveRepl {
117
+ private modelClient: ModelClient;
118
+ private sessionManager: SessionManager;
119
+ private toolRegistry: ToolRegistry;
120
+ private approvalManager: ApprovalManager;
121
+ private authManager?: AuthManager;
122
+ private config: NovaConfig;
123
+ private configManager: ConfigManager;
124
+ private cwd: string;
125
+ private contextCompressor?: any;
126
+ private mcpManager?: McpManager;
127
+ private skillRegistry?: SkillRegistry;
128
+ private rl: readline.Interface | null = null;
129
+ private currentLoop: AgentLoop | null = null;
130
+ private sessionId: SessionId | null = null;
131
+ private restoreSessionId?: string;
132
+ private resizeTimer: NodeJS.Timeout | null = null;
133
+
134
+ // ---- UX state ----
135
+ private mode: InteractionMode = 'auto';
136
+ private showThinking = true;
137
+ private compactMode = true;
138
+ private processing = false;
139
+
140
+ // ---- Multi-line input state ----
141
+ private multilineBuffer: string[] = [];
142
+ private isMultiline = false;
143
+
144
+ // ---- Components ----
145
+ private thinkingRenderer: ThinkingBlockRenderer;
146
+
147
+ // ---- Streaming state for current task ----
148
+ private activeToolCalls = new Map<string, ToolCallState>();
149
+ private toolCallOrder: string[] = [];
150
+ private currentTurn = 0;
151
+ private spinnerTimer: NodeJS.Timeout | null = null;
152
+ private spinnerFrame = 0;
153
+ private currentTaskTokens = 0;
154
+ private _pendingSkillInject: SkillDefinition | null = null;
155
+
156
+ // ---- Enhanced completer ----
157
+ private enhancedCompleter: EnhancedCompleter | null = null;
158
+ private inputHistory: string[] = [];
159
+
160
+ constructor(options: ReplOptions) {
161
+ this.modelClient = options.modelClient;
162
+ this.sessionManager = options.sessionManager;
163
+ this.toolRegistry = options.toolRegistry;
164
+ this.approvalManager = options.approvalManager;
165
+ this.authManager = options.authManager;
166
+ this.config = options.config;
167
+ this.configManager = options.configManager;
168
+ this.cwd = options.cwd;
169
+ this.contextCompressor = options.contextCompressor;
170
+ this.mcpManager = options.mcpManager;
171
+ this.skillRegistry = options.skillRegistry;
172
+ this.restoreSessionId = options.restoreSessionId;
173
+
174
+ this.thinkingRenderer = new ThinkingBlockRenderer({
175
+ expanded: false,
176
+ maxPreviewLines: 4,
177
+ maxLineLength: 80,
178
+ showStreamingPreview: false,
179
+ });
180
+
181
+ // Initialize enhanced completer with empty arrays (will be populated in start())
182
+ this.enhancedCompleter = new EnhancedCompleter({
183
+ configManager: this.configManager,
184
+ cwd: this.cwd,
185
+ history: this.inputHistory,
186
+ skills: [],
187
+ mcpServers: this.mcpManager ? this.mcpManager.listServers().map(s => ({ name: s.name, status: s.connected ? 'connected' : 'disconnected' })) : [],
188
+ });
189
+ }
190
+
191
+ // ========================================================================
192
+ // Lifecycle
193
+ // ========================================================================
194
+
195
+ async start(): Promise<void> {
196
+ this.printBanner();
197
+
198
+ // Update skills list now that we're in async context
199
+ if (this.skillRegistry && this.enhancedCompleter) {
200
+ const skills = await this.skillRegistry.list();
201
+ this.enhancedCompleter.updateSkills(skills.map(s => s.metadata.name));
202
+ }
203
+
204
+ // Restore or create session
205
+ if (this.restoreSessionId) {
206
+ const existing = this.sessionManager.get(this.restoreSessionId as SessionId);
207
+ this.sessionId = existing ? this.restoreSessionId as SessionId : this.createInitialSession();
208
+ if (existing) {
209
+ const msgs = this.sessionManager.getMessages(this.sessionId!);
210
+ console.log(C.info(` Restored session: ${String(this.sessionId).slice(0, 8)} — ${msgs.length} messages`));
211
+ }
212
+ } else {
213
+ this.sessionId = this.createInitialSession();
214
+ }
215
+
216
+ this.rl = readline.createInterface({
217
+ input: process.stdin,
218
+ output: process.stdout,
219
+ prompt: '',
220
+ historySize: 200,
221
+ removeHistoryDuplicates: true,
222
+ });
223
+
224
+ this.approvalManager.setHandler(this.handleApproval.bind(this));
225
+
226
+ process.on('SIGINT', () => {
227
+ if (this.currentLoop?.isActive()) {
228
+ this.currentLoop.cancel();
229
+ this.processing = false;
230
+ this.thinkingRenderer.cancel();
231
+ this.stopSpinner();
232
+ // Also persist on cancel
233
+ if (this.sessionId) this.sessionManager.persist(this.sessionId);
234
+ process.stdout.write('\n');
235
+ this.printLine('cancelled', 'warning');
236
+ this.printPrompt();
237
+ } else {
238
+ console.log(C.muted('\n Use /quit or Ctrl+D to exit'));
239
+ this.printPrompt();
240
+ }
241
+ });
242
+
243
+ // Handle terminal resize - redraw banner and status
244
+ let resizeTimer: NodeJS.Timeout | null = null;
245
+ process.stdout.on('resize', () => {
246
+ if (!this.processing) {
247
+ // Debounce resize events to avoid flickering
248
+ if (resizeTimer) {
249
+ clearTimeout(resizeTimer);
250
+ }
251
+ resizeTimer = setTimeout(() => {
252
+ console.clear();
253
+ this.printBanner();
254
+ }, 100);
255
+ }
256
+ });
257
+
258
+ // Custom input loop with boxed input area
259
+ this.runInputLoop();
260
+
261
+ this.rl.on('close', () => {
262
+ if (this.sessionId) this.sessionManager.persist(this.sessionId);
263
+ if (this.resizeTimer) {
264
+ clearTimeout(this.resizeTimer);
265
+ this.resizeTimer = null;
266
+ }
267
+ console.log(C.muted('\nGoodbye!'));
268
+ process.exit(0);
269
+ });
270
+ }
271
+
272
+ /** Custom input loop with boxed input area */
273
+ private async runInputLoop(): Promise<void> {
274
+ while (this.rl) {
275
+ // Print input box header
276
+ this.printInputBox();
277
+
278
+ // Get user input
279
+ const input = await this.askInput();
280
+
281
+ // Close input box
282
+ this.printInputBoxFooter();
283
+
284
+ if (!input) continue;
285
+
286
+ // Process input
287
+ this.processing = true;
288
+ await this.dispatchInput(input.trim());
289
+ this.processing = false;
290
+ }
291
+ }
292
+
293
+ /** Ask for input with proper prompt */
294
+ private askInput(): Promise<string> {
295
+ return new Promise((resolve) => {
296
+ if (!this.rl) return resolve('');
297
+
298
+ // Set simple prompt inside the box
299
+ this.rl.setPrompt(C.brand(BOX.v) + ' ');
300
+ this.rl.prompt();
301
+
302
+ // Tab completion state
303
+ let currentInput = '';
304
+ let completionsShown = false;
305
+
306
+ // Enable raw mode for Tab key detection
307
+ const wasRaw = process.stdin.isRaw;
308
+ if (process.stdin.isTTY) {
309
+ process.stdin.setRawMode(true);
310
+ }
311
+
312
+ const cleanup = () => {
313
+ if (process.stdin.isTTY) {
314
+ process.stdin.setRawMode(wasRaw ?? false);
315
+ }
316
+ process.stdin.off('data', onKeypress);
317
+ this.rl?.off('line', onLine);
318
+ };
319
+
320
+ const onKeypress = (buffer: Buffer) => {
321
+ const key = buffer.toString();
322
+
323
+ // Tab key - show completions
324
+ if (key === '\t') {
325
+ const completions = this.enhancedCompleter?.getCompletions(currentInput) || [];
326
+ if (completions.length > 0) {
327
+ process.stdout.write('\n');
328
+ this.showEnhancedCompletions(completions);
329
+ // Reprint prompt and current input
330
+ process.stdout.write(C.brand(BOX.v) + ' ' + currentInput);
331
+ completionsShown = true;
332
+ }
333
+ }
334
+ // Ctrl+C - cancel
335
+ else if (key === '\x03') {
336
+ cleanup();
337
+ resolve('');
338
+ }
339
+ // Backspace - update currentInput
340
+ else if (key === '\x7f' || key === '\b') {
341
+ if (currentInput.length > 0) {
342
+ currentInput = currentInput.slice(0, -1);
343
+ }
344
+ completionsShown = false;
345
+ }
346
+ // Regular character
347
+ else if (key.length === 1 && key >= ' ' && key <= '~') {
348
+ currentInput += key;
349
+ completionsShown = false;
350
+ }
351
+ };
352
+
353
+ const onLine = (line: string) => {
354
+ cleanup();
355
+
356
+ // Multi-line mode: lines ending with \ continue input
357
+ if (line.endsWith('\\')) {
358
+ this.isMultiline = true;
359
+ this.multilineBuffer.push(line.slice(0, -1));
360
+ // Continue reading without resolving
361
+ process.stdout.write(C.dim(' ' + BOX.arrowDown + ' '));
362
+ this.rl?.prompt();
363
+ return;
364
+ }
365
+
366
+ if (this.isMultiline) {
367
+ this.multilineBuffer.push(line);
368
+ // Check if empty line ends multiline mode
369
+ if (line.trim() === '') {
370
+ const fullInput = this.multilineBuffer.join('\n').trim();
371
+ this.multilineBuffer = [];
372
+ this.isMultiline = false;
373
+ resolve(fullInput);
374
+ } else {
375
+ process.stdout.write(C.dim(' ' + BOX.arrowDown + ' '));
376
+ this.rl?.prompt();
377
+ }
378
+ return;
379
+ }
380
+
381
+ resolve(line);
382
+ };
383
+
384
+ this.rl.on('line', onLine);
385
+ process.stdin.on('data', onKeypress);
386
+ });
387
+ }
388
+
389
+ /** Get all REPL commands for completion */
390
+ private getAllReplCommands(): { text: string; description: string }[] {
391
+ return [
392
+ { text: '/help', description: 'Show help' },
393
+ { text: '/quit', description: 'Exit nova' },
394
+ { text: '/clear', description: 'Clear conversation' },
395
+ { text: '/status', description: 'Session info' },
396
+ { text: '/model', description: 'Switch model' },
397
+ { text: '/mode', description: 'Change mode (auto/plan/ask)' },
398
+ { text: '/init', description: 'Generate NOVA.md' },
399
+ { text: '/memory', description: 'Manage memory' },
400
+ { text: '/history', description: 'Session history' },
401
+ { text: '/mcp', description: 'MCP servers' },
402
+ { text: '/skills', description: 'Available skills' },
403
+ { text: '/theme', description: 'Switch color theme' },
404
+ { text: '/checkpoint', description: 'File snapshots' },
405
+ { text: '/image', description: 'Add image to chat' },
406
+ { text: '/ollama', description: 'Ollama status' },
407
+ { text: '/thinking', description: 'Toggle thinking' },
408
+ { text: '/compact', description: 'Toggle compact mode' },
409
+ ];
410
+ }
411
+
412
+ /** Get completions for REPL input */
413
+ private getReplCompletions(input: string): { text: string; description: string }[] {
414
+ const commands = this.getAllReplCommands();
415
+ const partial = input.toLowerCase();
416
+ return commands.filter(cmd => cmd.text.toLowerCase().startsWith(partial));
417
+ }
418
+
419
+ /** Display completions */
420
+ private showCompletions(completions: { text: string; description: string }[]): void {
421
+ const maxLen = Math.max(...completions.map(c => c.text.length));
422
+ for (const c of completions.slice(0, 10)) {
423
+ console.log(` ${C.info(c.text.padEnd(maxLen + 2))} ${C.dim(c.description)}`);
424
+ }
425
+ if (completions.length > 10) {
426
+ console.log(C.dim(` ... and ${completions.length - 10} more`));
427
+ }
428
+ }
429
+
430
+ /** Display enhanced completions with type icons */
431
+ private showEnhancedCompletions(completions: CompletionCandidate[]): void {
432
+ const typeIcons: Record<string, string> = {
433
+ command: '⌘',
434
+ model: '🤖',
435
+ file: '📄',
436
+ directory: '📁',
437
+ history: '📜',
438
+ option: '⚙️',
439
+ skill: '🎯',
440
+ mcp: '🔌',
441
+ };
442
+
443
+ const typeColors: Record<string, any> = {
444
+ command: C.info,
445
+ model: C.brand,
446
+ file: C.muted,
447
+ directory: C.success,
448
+ history: C.subtle,
449
+ option: C.warning,
450
+ skill: C.accent,
451
+ mcp: C.toolName,
452
+ };
453
+
454
+ const maxLen = Math.max(...completions.map(c => c.displayText.length));
455
+ const limit = Math.min(completions.length, 12);
456
+
457
+ for (const c of completions.slice(0, limit)) {
458
+ const icon = typeIcons[c.type] || '•';
459
+ const color = typeColors[c.type] || C.primary;
460
+ const text = c.displayText.padEnd(maxLen + 2);
461
+ console.log(` ${icon} ${color(text)}${C.dim(c.description)}`);
462
+ }
463
+
464
+ if (completions.length > limit) {
465
+ console.log(C.dim(` ... and ${completions.length - limit} more`));
466
+ }
467
+ }
468
+
469
+ /** Route a single trimmed line to the correct handler */
470
+ private async dispatchInput(input: string): Promise<void> {
471
+ // /command
472
+ if (input.startsWith('/')) {
473
+ await this.handleCommand(input);
474
+ return;
475
+ }
476
+
477
+ // !shell command
478
+ if (input.startsWith('!')) {
479
+ await this.handleShellCommand(input.slice(1).trim());
480
+ return;
481
+ }
482
+
483
+ // Regular input (may contain @file references)
484
+ await this.processInput(input);
485
+ }
486
+
487
+ // ========================================================================
488
+ // @ File reference expansion
489
+ // ========================================================================
490
+
491
+ /**
492
+ * Expand @path references in user input.
493
+ * @src/App.tsx → inlines file content
494
+ * @src/components/ → inlines directory listing + all files under 50KB total
495
+ */
496
+ private async expandAtReferences(input: string): Promise<string> {
497
+ // Match @word/path.ext or @path patterns (not email addresses)
498
+ const atPattern = /@([\w./\-\\]+)/g;
499
+ const matches = [...input.matchAll(atPattern)];
500
+ if (matches.length === 0) return input;
501
+
502
+ let result = input;
503
+ const injections: string[] = [];
504
+
505
+ for (const match of matches) {
506
+ const refPath = match[1];
507
+ if (!refPath) continue;
508
+
509
+ const absPath = path.isAbsolute(refPath)
510
+ ? refPath
511
+ : path.resolve(this.cwd, refPath);
512
+
513
+ try {
514
+ if (!fs.existsSync(absPath)) {
515
+ injections.push(`[@ ${refPath}: file not found]`);
516
+ continue;
517
+ }
518
+
519
+ const stat = fs.statSync(absPath);
520
+
521
+ if (stat.isDirectory()) {
522
+ // Directory: list contents and include small files
523
+ const files = this.listDirRecursive(absPath, 3, 30);
524
+ const fileList = files.join('\n');
525
+ let content = `\n\`\`\`\n# Directory: ${refPath}\n${fileList}\n\`\`\`\n`;
526
+
527
+ // Include content of small text files (limit to 20 files, 100KB total)
528
+ let totalSize = 0;
529
+ let fileCount = 0;
530
+ for (const f of files) {
531
+ if (fileCount >= 20) break;
532
+ const fullPath = path.join(absPath, f);
533
+ try {
534
+ if (!fs.statSync(fullPath).isFile()) continue;
535
+ const size = fs.statSync(fullPath).size;
536
+ if (size > 50000 || totalSize + size > 100000) continue;
537
+ const ext = path.extname(f).slice(1);
538
+ if (!this.isTextFile(ext)) continue;
539
+ const fileContent = fs.readFileSync(fullPath, 'utf-8');
540
+ content += `\n\`\`\`${ext}\n# ${f}\n${fileContent}\n\`\`\`\n`;
541
+ totalSize += size;
542
+ fileCount++;
543
+ } catch { /* skip */ }
544
+ }
545
+
546
+ injections.push(content);
547
+ console.log(C.info(` @ ${refPath} → directory (${files.length} files)`));
548
+ } else {
549
+ // Single file
550
+ const size = stat.size;
551
+ if (size > 200 * 1024) {
552
+ injections.push(`[@ ${refPath}: file too large (${(size / 1024).toFixed(0)} KB), please be more specific]`);
553
+ console.log(C.warning(` @ ${refPath} → too large (${(size / 1024).toFixed(0)} KB)`));
554
+ continue;
555
+ }
556
+ const ext = path.extname(refPath).slice(1);
557
+ const fileContent = fs.readFileSync(absPath, 'utf-8');
558
+ injections.push(`\n\`\`\`${ext}\n# ${refPath}\n${fileContent}\n\`\`\`\n`);
559
+ console.log(C.info(` @ ${refPath} → ${(size / 1024).toFixed(1)} KB`));
560
+ }
561
+ } catch (err) {
562
+ injections.push(`[@ ${refPath}: error reading file — ${(err as Error).message}]`);
563
+ }
564
+ }
565
+
566
+ // Append all injected content below the original input
567
+ if (injections.length > 0) {
568
+ result = input + '\n\n' + injections.join('\n');
569
+ }
570
+ return result;
571
+ }
572
+
573
+ private isTextFile(ext: string): boolean {
574
+ const TEXT_EXTS = new Set([
575
+ 'ts','tsx','js','jsx','mjs','cjs','json','yaml','yml','toml','ini',
576
+ 'md','txt','html','css','scss','less','sh','bash','zsh','fish',
577
+ 'py','rb','go','rs','java','c','cpp','h','hpp','cs','php','swift',
578
+ 'vue','svelte','astro','graphql','sql','env','gitignore','lock',
579
+ ]);
580
+ return TEXT_EXTS.has(ext.toLowerCase());
581
+ }
582
+
583
+ private listDirRecursive(dir: string, maxDepth: number, maxFiles: number): string[] {
584
+ const results: string[] = [];
585
+ const walk = (current: string, depth: number, prefix: string) => {
586
+ if (depth > maxDepth || results.length >= maxFiles) return;
587
+ try {
588
+ const entries = fs.readdirSync(current).filter((e) =>
589
+ !e.startsWith('.') && e !== 'node_modules' && e !== 'dist' && e !== '__pycache__'
590
+ );
591
+ for (const e of entries) {
592
+ if (results.length >= maxFiles) break;
593
+ const fullPath = path.join(current, e);
594
+ const rel = prefix ? `${prefix}/${e}` : e;
595
+ results.push(rel);
596
+ if (fs.statSync(fullPath).isDirectory()) {
597
+ walk(fullPath, depth + 1, rel);
598
+ }
599
+ }
600
+ } catch { /* skip */ }
601
+ };
602
+ walk(dir, 0, '');
603
+ return results;
604
+ }
605
+
606
+ // ========================================================================
607
+ // Prompt & Banner
608
+ // ========================================================================
609
+
610
+ private getPromptText(): string {
611
+ const modeInfo = MODE_LABELS[this.mode];
612
+ const modelShort = this.modelClient.getModel().split('/').pop() || this.modelClient.getModel();
613
+
614
+ if (this.isMultiline) {
615
+ // Multi-line mode indicator
616
+ return C.dim(' ' + BOX.arrowDown + ' ');
617
+ }
618
+
619
+ // Compact prompt: [MODE] model ›
620
+ const modeBadge = modeInfo.color(`[${modeInfo.label}]`);
621
+ const modelPart = C.muted(modelShort);
622
+ return `\n${modeBadge} ${modelPart} ${C.brand(BOX.arrowRight)} `;
623
+ }
624
+
625
+ private printPrompt(): void {
626
+ if (this.rl) {
627
+ const promptText = this.getPromptText();
628
+ this.rl.setPrompt(promptText);
629
+ this.rl.prompt();
630
+ }
631
+ }
632
+
633
+ /** Get terminal width with min/max constraints */
634
+ private getTermWidth(min = 40, max = 120): number {
635
+ const cols = process.stdout.columns || 80;
636
+ return Math.max(min, Math.min(cols - 4, max));
637
+ }
638
+
639
+ /** Print input box frame before prompt */
640
+ private printInputBox(): void {
641
+ const modeInfo = MODE_LABELS[this.mode];
642
+ const modelShort = this.modelClient.getModel().split('/').pop() || this.modelClient.getModel();
643
+ const w = this.getTermWidth(40, 100);
644
+
645
+ // Get session stats for context usage
646
+ let contextInfo = '';
647
+ if (this.sessionId) {
648
+ const stats = this.sessionManager.getStats(this.sessionId);
649
+ if (stats) {
650
+ const totalTokens = (stats.totalInputTokens || 0) + (stats.totalOutputTokens || 0);
651
+ const maxContext = this.config.core.maxTokens * 8 || 128000;
652
+ const pct = Math.min(100, Math.round((totalTokens / maxContext) * 100));
653
+ const pctColor = pct > 80 ? C.error : pct > 50 ? C.warning : C.success;
654
+ contextInfo = pctColor(`${pct}%`) + C.dim(' ctx');
655
+ }
656
+ }
657
+
658
+ // Input box header
659
+ const modeBadge = modeInfo.color(`[${modeInfo.label}]`);
660
+ const headerText = `${modeBadge} ${C.muted(modelShort)}${contextInfo ? ' ' + contextInfo : ''}`;
661
+ const visibleLen = modeInfo.label.length + 2 + 1 + modelShort.length + (contextInfo ? 8 : 0);
662
+ const headerPadding = Math.max(0, w - visibleLen - 3);
663
+
664
+ console.log('');
665
+ console.log(C.brand(BOX.tl) + C.brand(BOX.hThick.repeat(w)) + C.brand(BOX.tr));
666
+ console.log(C.brand(BOX.v) + ' ' + headerText + ' '.repeat(headerPadding) + C.brand(BOX.v));
667
+ console.log(C.brand(BOX.ht) + C.brandDim(BOX.h.repeat(w)) + C.brand(BOX.htr));
668
+ }
669
+
670
+ /** Print input box footer after user submits */
671
+ private printInputBoxFooter(): void {
672
+ const w = this.getTermWidth(40, 100);
673
+ console.log(C.brand(BOX.bl) + C.brand(BOX.hThick.repeat(w)) + C.brand(BOX.br));
674
+ }
675
+
676
+ /** Print compact status bar after AI response */
677
+ private printStatusBar(): void {
678
+ const modelShort = this.modelClient.getModel().split('/').pop() || this.modelClient.getModel();
679
+ const modeInfo = MODE_LABELS[this.mode];
680
+
681
+ let contextInfo = '';
682
+ if (this.sessionId) {
683
+ const stats = this.sessionManager.getStats(this.sessionId);
684
+ if (stats) {
685
+ const totalTokens = (stats.totalInputTokens || 0) + (stats.totalOutputTokens || 0);
686
+ const maxContext = this.config.core.maxTokens * 8 || 128000;
687
+ const pct = Math.min(100, Math.round((totalTokens / maxContext) * 100));
688
+ const pctColor = pct > 80 ? C.error : pct > 50 ? C.warning : C.success;
689
+ contextInfo = pctColor(`${pct}%`);
690
+ }
691
+ }
692
+
693
+ const parts = [
694
+ C.muted('Model:') + ' ' + C.primary(modelShort),
695
+ C.muted('Mode:') + ' ' + modeInfo.color(modeInfo.label),
696
+ contextInfo ? C.muted('Context:') + ' ' + contextInfo : '',
697
+ ].filter(Boolean);
698
+
699
+ console.log(C.dim(' ' + BOX.h.repeat(4) + ' ') + parts.join(C.dim(' · ')) + C.dim(' ' + BOX.h.repeat(4)));
700
+ }
701
+
702
+ private printBanner(): void {
703
+ const modeInfo = MODE_LABELS[this.mode];
704
+ const modelShort = this.modelClient.getModel().split('/').pop() || this.modelClient.getModel();
705
+ const termCols = process.stdout.columns || 80;
706
+ // Use a reasonable max width, but don't exceed terminal width
707
+ const w = Math.min(termCols - 4, 76);
708
+
709
+ const hr = C.brandDim(BOX.h.repeat(w));
710
+ const hrThick = C.brand(BOX.hThick.repeat(w));
711
+ const vl = C.brandDim(BOX.v);
712
+
713
+ // Simple compact header instead of big ASCII art
714
+ console.log('');
715
+ console.log(C.brand(BOX.tl) + hrThick + C.brand(BOX.tr));
716
+
717
+ // Compact logo line
718
+ const logoLine = C.brand(' NOVA ') + C.brandLight('CLI') + C.dim(' · AI-powered terminal assistant');
719
+ const logoPadding = Math.max(0, w - 38); // 38 = visible chars in logoLine (without ANSI codes)
720
+ console.log(vl + logoLine + ' '.repeat(logoPadding) + vl);
721
+
722
+ console.log(C.brand(BOX.ht) + hr + C.brand(BOX.htr));
723
+
724
+ // Status line 1: Model | Dir
725
+ const modelLabel = C.dim('Model: ');
726
+ const modelVal = C.primary(modelShort);
727
+ const dirLabel = C.dim('Dir: ');
728
+ const dirVal = C.muted(this.cwd.length > 40 ? '...' + this.cwd.slice(-37) : this.cwd);
729
+ const line1 = ` ${modelLabel}${modelVal} ${C.dim(BOX.v)} ${dirLabel}${dirVal}`;
730
+ console.log(vl + line1 + ' '.repeat(Math.max(0, w - 10 - modelShort.length)) + vl);
731
+
732
+ // Status line 2: Mode | Session
733
+ const modeLabel = C.dim('Mode: ');
734
+ const modeVal = modeInfo.color(modeInfo.label);
735
+ const sessLabel = C.dim('Session: ');
736
+ const sessionText = this.restoreSessionId ? this.restoreSessionId.slice(0, 8) : 'new';
737
+ const sessVal = C.muted(sessionText);
738
+ const line2 = ` ${modeLabel}${modeVal} ${C.dim(BOX.v)} ${sessLabel}${sessVal}`;
739
+ console.log(vl + line2 + ' '.repeat(Math.max(0, w - 24)) + vl);
740
+
741
+ // Status line 3: MCP
742
+ let mcpStatus = C.dim('○');
743
+ let mcpText = 'none';
744
+ if (this.mcpManager) {
745
+ const statuses = this.mcpManager.listServers();
746
+ if (statuses.length > 0) {
747
+ const connected = statuses.filter((s) => s.connected).length;
748
+ const total = statuses.length;
749
+ mcpStatus = connected === total ? C.success(BOX.check) : connected > 0 ? C.warning('◐') : C.error(BOX.crossX);
750
+ mcpText = `${connected}/${total}`;
751
+ }
752
+ }
753
+ const mcpLabel = C.dim('MCP: ');
754
+ const line3 = ` ${mcpLabel}${mcpStatus} ${C.muted(mcpText)}`;
755
+ console.log(vl + line3 + ' '.repeat(Math.max(0, w - 16)) + vl);
756
+
757
+ console.log(C.brand(BOX.ht) + hr + C.brand(BOX.htr));
758
+
759
+ // Commands help - compact
760
+ const cmdLine = C.dim(' Commands: ') +
761
+ C.primary('/help') + C.dim(', ') +
762
+ C.primary('/mode') + C.dim(', ') +
763
+ C.primary('/model') + C.dim(', ') +
764
+ C.primary('/init') + C.dim(', ') +
765
+ C.primary('/quit');
766
+ console.log(vl + cmdLine + ' '.repeat(Math.max(0, w - 52)) + vl);
767
+
768
+ // Shortcuts
769
+ const shortcutLine = C.dim(' Shortcuts: ') +
770
+ C.info('@file') + C.dim(' inject, ') +
771
+ C.info('!cmd') + C.dim(' shell, ') +
772
+ C.info('\\') + C.dim(' multiline');
773
+ console.log(vl + shortcutLine + ' '.repeat(Math.max(0, w - 52)) + vl);
774
+
775
+ // Bottom border
776
+ console.log(C.brand(BOX.bl) + hrThick + C.brand(BOX.br));
777
+ console.log('');
778
+ }
779
+
780
+ // ========================================================================
781
+ // UI Helpers
782
+ // ========================================================================
783
+
784
+ private printLine(label: string, type: 'info' | 'success' | 'warning' | 'error' | 'muted' = 'muted'): void {
785
+ const colors = { info: C.info, success: C.success, warning: C.warning, error: C.error, muted: C.muted };
786
+ const color = colors[type];
787
+ const w = this.getTermWidth(40, 80);
788
+ const padded = ` ${label} `;
789
+ const left = Math.floor((w - padded.length) / 2);
790
+ const right = w - padded.length - left;
791
+ console.log(C.dim(BOX.h.repeat(left)) + color(padded) + C.dim(BOX.h.repeat(right)));
792
+ }
793
+
794
+ private startSpinner(msg: string): void {
795
+ this.stopSpinner();
796
+ this.spinnerFrame = 0;
797
+ this.spinnerTimer = setInterval(() => {
798
+ const frame = BOX.spinner[this.spinnerFrame % BOX.spinner.length];
799
+ process.stdout.write(`\r${C.brand(frame)} ${C.muted(msg)}`);
800
+ this.spinnerFrame++;
801
+ }, 80);
802
+ }
803
+
804
+ private stopSpinner(): void {
805
+ if (this.spinnerTimer) {
806
+ clearInterval(this.spinnerTimer);
807
+ this.spinnerTimer = null;
808
+ const clearWidth = Math.min(60, (process.stdout.columns || 80) - 1);
809
+ process.stdout.write('\r' + ' '.repeat(clearWidth) + '\r');
810
+ }
811
+ }
812
+
813
+ // ========================================================================
814
+ // !shell direct execution
815
+ // ========================================================================
816
+
817
+ private async handleShellCommand(cmd: string): Promise<void> {
818
+ if (!cmd) {
819
+ console.log(C.muted(' Usage: !<command> e.g. !ls, !git status, !npm test'));
820
+ return;
821
+ }
822
+
823
+ console.log('');
824
+ console.log(C.muted(` $ ${cmd}`));
825
+ const startTime = Date.now();
826
+
827
+ try {
828
+ // Use spawn for streaming output
829
+ const isWin = process.platform === 'win32';
830
+ const shell = isWin ? 'powershell.exe' : '/bin/sh';
831
+ const shellArgs = isWin ? ['-Command', cmd] : ['-c', cmd];
832
+
833
+ await new Promise<void>((resolve, reject) => {
834
+ const child = spawn(shell, shellArgs, {
835
+ cwd: this.cwd,
836
+ stdio: ['inherit', 'pipe', 'pipe'],
837
+ });
838
+
839
+ child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(C.primary(chunk.toString())));
840
+ child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(C.warning(chunk.toString())));
841
+
842
+ child.on('close', (code) => {
843
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
844
+ if (code === 0) {
845
+ console.log('');
846
+ console.log(C.success(` ✓ exit 0`) + C.dim(` (${duration}s)`));
847
+ } else {
848
+ console.log('');
849
+ console.log(C.error(` ✗ exit ${code}`) + C.dim(` (${duration}s)`));
850
+ }
851
+ resolve();
852
+ });
853
+
854
+ child.on('error', reject);
855
+ });
856
+ } catch (err) {
857
+ console.log(C.error(` Error: ${(err as Error).message}`));
858
+ }
859
+ }
860
+
861
+ // ========================================================================
862
+ // Agent loop processing
863
+ // ========================================================================
864
+
865
+ private async processInput(input: string): Promise<void> {
866
+ if (!this.sessionId) return;
867
+
868
+ this.processing = true;
869
+ this.activeToolCalls.clear();
870
+ this.toolCallOrder = [];
871
+ this.currentTurn = 0;
872
+ this.currentTaskTokens = 0;
873
+
874
+ // Expand @file references
875
+ const expandedInput = await this.expandAtReferences(input);
876
+
877
+ // Show user message (original, not expanded)
878
+ console.log('');
879
+ console.log(C.muted(' ' + BOX.bullet + ' ') + C.primary(input));
880
+ this.printLine('running', 'info');
881
+
882
+ const modePrefix = this.getModePrefix();
883
+
884
+ // Skill injection
885
+ let skillPrefix = '';
886
+ if (this._pendingSkillInject) {
887
+ const skillName = this._pendingSkillInject.metadata.name;
888
+ skillPrefix = `[SKILL: ${skillName}]\n${this._pendingSkillInject.content}\n[/SKILL]\n\n`;
889
+ console.log(C.info(` ⚡ Skill "${skillName}" injected`));
890
+ this._pendingSkillInject = null;
891
+ }
892
+ const fullInput = [modePrefix, skillPrefix, expandedInput].filter(Boolean).join('\n\n');
893
+
894
+ try {
895
+ const effectiveApprovalMode = this.getEffectiveApprovalMode();
896
+ const systemPrompt = buildSystemPrompt({
897
+ workingDirectory: this.cwd,
898
+ model: this.modelClient.getModel(),
899
+ approvalMode: effectiveApprovalMode,
900
+ });
901
+
902
+ const agentLoop = new AgentLoop({
903
+ modelClient: this.modelClient,
904
+ sessionManager: this.sessionManager,
905
+ toolRegistry: this.toolRegistry,
906
+ systemPrompt,
907
+ contextCompressor: this.contextCompressor,
908
+ maxContextTokens: (this.config.core.maxTokens || 16384) * 8,
909
+
910
+ onTextDelta: (text: string) => {
911
+ this.stopSpinner();
912
+ process.stdout.write(text);
913
+ },
914
+
915
+ onToolStart: (name, toolCallId) => {
916
+ this.stopSpinner();
917
+ if (this.thinkingRenderer.isRendering()) {
918
+ this.thinkingRenderer.cancel();
919
+ process.stdout.write('\n');
920
+ }
921
+
922
+ const state: ToolCallState = {
923
+ name, toolCallId,
924
+ startTime: Date.now(),
925
+ input: '', result: '',
926
+ isError: false, isComplete: false,
927
+ lineIndex: this.toolCallOrder.length,
928
+ };
929
+ this.activeToolCalls.set(toolCallId, state);
930
+ this.toolCallOrder.push(toolCallId);
931
+ this.printToolStart(state);
932
+ },
933
+
934
+ onToolComplete: (name, toolCallId, result) => {
935
+ const state = this.activeToolCalls.get(toolCallId);
936
+ if (state) {
937
+ state.result = typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
938
+ state.isError = !!result.isError;
939
+ state.isComplete = true;
940
+ this.printToolComplete(state);
941
+ if (name === 'todo' && !result.isError) {
942
+ this.printTodoPanel(state.result);
943
+ }
944
+ }
945
+ },
946
+
947
+ onThinkingStart: () => {
948
+ if (!this.showThinking) return;
949
+ this.stopSpinner();
950
+ this.thinkingRenderer.start();
951
+ },
952
+ onThinkingDelta: (delta: string) => {
953
+ if (!this.showThinking) return;
954
+ this.thinkingRenderer.append(delta);
955
+ },
956
+ onThinkingEnd: () => {
957
+ if (!this.showThinking) return;
958
+ this.thinkingRenderer.complete();
959
+ },
960
+
961
+ onApprovalRequired: this.handleApproval.bind(this),
962
+
963
+ onTurnStart: (turn) => {
964
+ this.currentTurn = turn;
965
+ if (turn > 1) {
966
+ console.log('');
967
+ console.log(C.dim(' ' + BOX.h.repeat(4) + ` turn ${turn} ` + BOX.h.repeat(4)));
968
+ }
969
+ this.activeToolCalls.clear();
970
+ this.toolCallOrder = [];
971
+ this.startSpinner(`turn ${turn} — thinking...`);
972
+ },
973
+
974
+ onTurnEnd: () => {
975
+ this.stopSpinner();
976
+ },
977
+
978
+ onContextCompress: (orig, result, action) => {
979
+ console.log(C.muted(`\n ${BOX.arrow} context compressed: ${orig} → ${result} tokens (${action})`));
980
+ },
981
+ });
982
+
983
+ this.currentLoop = agentLoop;
984
+ const startTime = Date.now();
985
+ const result = await agentLoop.runStream(this.sessionId, fullInput);
986
+ const duration = Date.now() - startTime;
987
+ this.currentLoop = null;
988
+ this.processing = false;
989
+ this.stopSpinner();
990
+
991
+ // Auto-persist session after every turn
992
+ this.sessionManager.persist(this.sessionId);
993
+
994
+ console.log('');
995
+ const totalTokens = result.totalInputTokens + result.totalOutputTokens;
996
+
997
+ // Compact completion summary with icons
998
+ const summaryParts = [
999
+ `${C.success(BOX.check)} ${C.muted(`${result.turnsCompleted} turn${result.turnsCompleted > 1 ? 's' : ''}`)}`,
1000
+ `${C.info(BOX.diamond)} ${C.muted(`${totalTokens.toLocaleString()} tok`)}`,
1001
+ `${C.accent(BOX.star)} ${C.muted(`${(duration / 1000).toFixed(1)}s`)}`,
1002
+ ];
1003
+
1004
+ console.log(
1005
+ C.dim(' ' + BOX.h.repeat(4)) + ' ' +
1006
+ C.success('Done') + ' ' +
1007
+ summaryParts.join(C.dim(' · ')) + ' ' +
1008
+ C.dim(BOX.h.repeat(4))
1009
+ );
1010
+
1011
+ } catch (err: unknown) {
1012
+ this.currentLoop = null;
1013
+ this.processing = false;
1014
+ this.thinkingRenderer.cancel();
1015
+ this.stopSpinner();
1016
+
1017
+ if (err && typeof err === 'object' && 'name' in err && (err as { name: string }).name === 'CancelledError') {
1018
+ return;
1019
+ }
1020
+ console.log('');
1021
+ this.printLine('error', 'error');
1022
+ console.error(C.error(` ${(err as Error).message}`));
1023
+ }
1024
+ }
1025
+
1026
+ // ========================================================================
1027
+ // Tool call display
1028
+ // ========================================================================
1029
+
1030
+ private printToolStart(state: ToolCallState): void {
1031
+ const idx = this.toolCallOrder.indexOf(state.toolCallId) + 1;
1032
+ const idxStr = idx.toString().padStart(2, '0');
1033
+
1034
+ if (this.compactMode) {
1035
+ process.stdout.write(
1036
+ '\n' +
1037
+ C.dim(` ${BOX.arrow} `) +
1038
+ C.toolName(state.name) + ' ' +
1039
+ C.dim(`#${idxStr}`)
1040
+ );
1041
+ } else {
1042
+ const time = this.getTimeStr();
1043
+ console.log('\n' + C.dim(` [${time}] `) + C.toolName(state.name) + C.dim(` #${idxStr}`));
1044
+ }
1045
+ }
1046
+
1047
+ private printTodoPanel(result: string): void {
1048
+ if (!result || result === 'No tasks tracked.' || result === 'All tasks cleared.') return;
1049
+
1050
+ const lines = result.split('\n').filter((l) => l.trim());
1051
+ if (lines.length === 0) return;
1052
+
1053
+ console.log('');
1054
+ console.log(C.brand.dim(' ┌─ ') + C.brand.dim('Tasks'));
1055
+
1056
+ for (const line of lines) {
1057
+ const pendingMatch = line.match(/^○\s+\[pending\s*\]\s+(.+)/);
1058
+ const inProgressMatch = line.match(/^◉\s+\[in_progress\s*\]\s+(.+)/);
1059
+ const completedMatch = line.match(/^●\s+\[completed\s*\]\s+(.+)/);
1060
+
1061
+ if (completedMatch) {
1062
+ console.log(C.brand.dim(' │ ') + C.success(BOX.check + ' ') + C.muted(completedMatch[1]));
1063
+ } else if (inProgressMatch) {
1064
+ console.log(C.brand.dim(' │ ') + C.warning('▶ ') + C.primary(inProgressMatch[1]));
1065
+ } else if (pendingMatch) {
1066
+ console.log(C.brand.dim(' │ ') + C.dim(BOX.dot + ' ') + C.muted(pendingMatch[1]));
1067
+ } else {
1068
+ console.log(C.brand.dim(' │ ') + C.muted(line));
1069
+ }
1070
+ }
1071
+
1072
+ const total = lines.length;
1073
+ const done = lines.filter((l) => l.startsWith('●')).length;
1074
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
1075
+ const bar = this.renderProgressBar(pct, 20);
1076
+
1077
+ console.log(C.brand.dim(' └─ ') + bar + C.muted(` ${done}/${total}`));
1078
+ console.log('');
1079
+ }
1080
+
1081
+ private renderProgressBar(pct: number, width: number): string {
1082
+ const filled = Math.round((pct / 100) * width);
1083
+ const empty = width - filled;
1084
+ const bar = C.success('█'.repeat(filled)) + C.dim('░'.repeat(empty));
1085
+ return C.muted('[') + bar + C.muted(']') + C.muted(` ${pct}%`);
1086
+ }
1087
+
1088
+ private printToolComplete(state: ToolCallState): void {
1089
+ const duration = Date.now() - state.startTime;
1090
+ const durationStr = duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`;
1091
+ const idx = this.toolCallOrder.indexOf(state.toolCallId) + 1;
1092
+ const idxStr = idx.toString().padStart(2, '0');
1093
+
1094
+ if (this.compactMode) {
1095
+ process.stdout.write('\r' + ' '.repeat(70) + '\r');
1096
+
1097
+ if (state.isError) {
1098
+ const preview = state.result.slice(0, 50).replace(/\n/g, ' ');
1099
+ console.log(
1100
+ C.dim(` ${BOX.crossX} `) +
1101
+ C.toolErr(state.name) + ' ' +
1102
+ C.dim(`#${idxStr}`) + ' ' +
1103
+ C.errorDim(`${durationStr}`) + ' ' +
1104
+ C.error.dim(preview)
1105
+ );
1106
+ } else {
1107
+ console.log(
1108
+ C.dim(` ${BOX.check} `) +
1109
+ C.toolName(state.name) + ' ' +
1110
+ C.dim(`#${idxStr}`) + ' ' +
1111
+ C.successDim(`${durationStr}`)
1112
+ );
1113
+ }
1114
+ } else {
1115
+ const prefix = C.dim(` ${state.isError ? BOX.crossX : BOX.check} `);
1116
+ if (state.isError) {
1117
+ const preview = state.result.slice(0, 80).replace(/\n/g, ' ');
1118
+ console.log(prefix + C.toolErr(state.name) + ' ' + C.error.dim(preview));
1119
+ } else {
1120
+ const preview = state.result.slice(0, 100).replace(/\n/g, ' ');
1121
+ console.log(prefix + C.toolName(state.name) + ' ' + C.dim(preview));
1122
+ }
1123
+ }
1124
+ }
1125
+
1126
+ // ========================================================================
1127
+ // Mode & toggle operations
1128
+ // ========================================================================
1129
+
1130
+ private cycleMode(): void {
1131
+ const idx = MODES.indexOf(this.mode);
1132
+ const nextIdx = (idx + 1) % MODES.length;
1133
+ this.mode = MODES[nextIdx] ?? 'auto';
1134
+ const info = MODE_LABELS[this.mode];
1135
+ console.log(
1136
+ C.dim(' ' + BOX.arrowRight) + ' ' +
1137
+ C.muted('Mode: ') + info.color(info.label) + ' ' +
1138
+ C.dim('·') + ' ' + C.muted(info.description)
1139
+ );
1140
+ }
1141
+
1142
+ private toggleThinking(): void {
1143
+ this.showThinking = !this.showThinking;
1144
+ const status = this.showThinking ? C.success('ON') : C.error('OFF');
1145
+ const icon = this.showThinking ? C.success(BOX.check) : C.error(BOX.crossX);
1146
+ console.log(C.dim(` ${icon} Thinking: ${status}`));
1147
+ }
1148
+
1149
+ private toggleCompact(): void {
1150
+ this.compactMode = !this.compactMode;
1151
+ this.thinkingRenderer.setExpanded(!this.compactMode);
1152
+ const status = this.compactMode ? C.success('compact') : C.info('verbose');
1153
+ console.log(C.dim(` ${BOX.diamond} Display: ${status}`));
1154
+ }
1155
+
1156
+ // ========================================================================
1157
+ // Commands
1158
+ // ========================================================================
1159
+
1160
+ private async handleCommand(cmd: string): Promise<void> {
1161
+ const parts = cmd.slice(1).split(/\s+/);
1162
+ const command = parts[0];
1163
+ const arg = parts.slice(1).join(' ');
1164
+
1165
+ switch (command) {
1166
+ case 'quit': case 'exit': case 'q':
1167
+ if (this.sessionId) this.sessionManager.persist(this.sessionId);
1168
+ console.log(C.muted('Goodbye!'));
1169
+ process.exit(0);
1170
+
1171
+ case 'help': case 'h': case '?':
1172
+ this.printHelp();
1173
+ break;
1174
+
1175
+ case 'clear': case 'reset':
1176
+ if (this.sessionId) this.sessionManager.persist(this.sessionId);
1177
+ this.sessionId = this.createInitialSession();
1178
+ console.log(C.muted(' Conversation cleared. New session started.'));
1179
+ break;
1180
+
1181
+ case 'model':
1182
+ await this.handleModelCommand(arg);
1183
+ break;
1184
+
1185
+ case 'mode':
1186
+ if (arg && MODES.includes(arg as InteractionMode)) {
1187
+ this.mode = arg as InteractionMode;
1188
+ const info = MODE_LABELS[this.mode];
1189
+ console.log(C.muted(' Mode: ') + info.color(info.label) + C.muted(` — ${info.description}`));
1190
+ console.log(C.muted(` Approval: `) + C.info(info.approvalMode));
1191
+ } else {
1192
+ this.cycleMode();
1193
+ }
1194
+ break;
1195
+
1196
+ case 'thinking':
1197
+ this.toggleThinking();
1198
+ break;
1199
+
1200
+ case 'compact':
1201
+ this.toggleCompact();
1202
+ break;
1203
+
1204
+ case 'tools': {
1205
+ const tools = this.toolRegistry.getEnabledToolNames();
1206
+ const stats = this.toolRegistry.getStats();
1207
+ console.log(C.muted(` Tools (${stats.enabled}): `) + C.primary(tools.slice(0, 10).join(', ') + (tools.length > 10 ? '...' : '')));
1208
+ break;
1209
+ }
1210
+
1211
+ case 'mcp':
1212
+ await this.handleMcpCommand(arg);
1213
+ break;
1214
+
1215
+ case 'ollama':
1216
+ await this.handleOllamaCommand(arg);
1217
+ break;
1218
+
1219
+ case 'skills':
1220
+ await this.handleSkillsCommand(arg);
1221
+ break;
1222
+
1223
+ case 'theme':
1224
+ await this.handleThemeCommand(arg);
1225
+ break;
1226
+
1227
+ case 'image':
1228
+ await this.handleImageCommand(arg);
1229
+ break;
1230
+
1231
+ case 'checkpoint':
1232
+ await this.handleCheckpointCommand(arg);
1233
+ break;
1234
+
1235
+ case 'init':
1236
+ await this.handleInitCommand(arg);
1237
+ break;
1238
+
1239
+ case 'memory':
1240
+ await this.handleMemoryCommand(arg);
1241
+ break;
1242
+
1243
+ case 'history':
1244
+ await this.handleHistoryCommand(arg);
1245
+ break;
1246
+
1247
+ case 'compress':
1248
+ await this.handleCompressCommand();
1249
+ break;
1250
+
1251
+ case 'status':
1252
+ if (this.sessionId) {
1253
+ const stats = this.sessionManager.getStats(this.sessionId);
1254
+ const modeInfo = MODE_LABELS[this.mode];
1255
+ console.log(C.muted(' Session: ') + C.primary(stats?.id?.slice(0, 8) || ''));
1256
+ console.log(C.muted(' Mode: ') + modeInfo.color(modeInfo.label));
1257
+ console.log(C.muted(' Turns: ') + C.primary(String(stats?.turnCount || 0)));
1258
+ console.log(C.muted(' Tokens: ') + C.primary(`${stats?.totalInputTokens || 0} in / ${stats?.totalOutputTokens || 0} out`));
1259
+ console.log(C.muted(' Msgs: ') + C.primary(String(stats?.messageCount || 0)));
1260
+ }
1261
+ break;
1262
+
1263
+ default:
1264
+ console.log(C.warning(` Unknown command: /${command}. Type /help for help.`));
1265
+ }
1266
+ }
1267
+
1268
+ // ========================================================================
1269
+ // Helper: Prompt for API key
1270
+ // ========================================================================
1271
+
1272
+ private async promptForApiKey(providerName: string, providerType: string): Promise<string | null> {
1273
+ const readline = await import('node:readline');
1274
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1275
+
1276
+ const envKey = `${providerName.toUpperCase()}_API_KEY`;
1277
+
1278
+ console.log('');
1279
+ console.log(C.warning(` No API key found for "${providerName}"`));
1280
+ console.log(C.muted(` You can also set it via: export ${envKey}=<your-key>`));
1281
+ console.log('');
1282
+
1283
+ const question = (prompt: string): Promise<string> =>
1284
+ new Promise((resolve) => rl.question(prompt, resolve));
1285
+
1286
+ try {
1287
+ const answer = await question(` Enter ${providerName} API key (or press Enter to skip): `);
1288
+ rl.close();
1289
+ return answer.trim() || null;
1290
+ } catch {
1291
+ rl.close();
1292
+ return null;
1293
+ }
1294
+ }
1295
+
1296
+ // ========================================================================
1297
+ // /model <id> — switch model at runtime (with interactive selector)
1298
+ // ========================================================================
1299
+
1300
+ private async handleModelCommand(arg: string): Promise<void> {
1301
+ if (!arg) {
1302
+ // Show interactive model selector
1303
+ await this.showModelSelector();
1304
+ return;
1305
+ }
1306
+ try {
1307
+ this.modelClient.updateOptions({ model: arg });
1308
+ console.log(C.success(` ✓ Switched to model: `) + C.primary(arg));
1309
+
1310
+ // Save to global config
1311
+ const config = this.configManager.getConfig();
1312
+ config.core.defaultModel = arg;
1313
+ await this.configManager.save(config);
1314
+
1315
+ // Update session config too
1316
+ if (this.sessionId) {
1317
+ const cfg = this.sessionManager.getConfig(this.sessionId);
1318
+ cfg.model = arg;
1319
+ }
1320
+ } catch (err) {
1321
+ console.log(C.error(` Failed to switch model: ${(err as Error).message}`));
1322
+ }
1323
+ }
1324
+
1325
+ /** Get all available models across all providers */
1326
+ private getAvailableModels(showAll: boolean = false): { id: string; name: string; provider: string; configured: boolean; providerType: string }[] {
1327
+ const currentModel = this.modelClient.getModel();
1328
+ const models: { id: string; name: string; provider: string; configured: boolean; providerType: string }[] = [];
1329
+ const config = this.configManager.getConfig();
1330
+
1331
+ // Collect models from configured providers only
1332
+ for (const [providerKey, providerConfig] of Object.entries(config.models.providers)) {
1333
+ // Check if provider is actually configured
1334
+ const hasCreds = this.authManager?.hasCredentials(providerKey) || false;
1335
+ const isOllama = providerConfig.type === 'ollama';
1336
+ const isOllamaCloud = providerConfig.type === 'ollama-cloud';
1337
+
1338
+ // For Ollama, check if it's actually running
1339
+ let ollamaRunning = false;
1340
+ if (isOllama) {
1341
+ try {
1342
+ const ollamaCreds = this.authManager?.getCredentials('ollama');
1343
+ const baseUrl = ollamaCreds?.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434';
1344
+ const manager = new OllamaManager(baseUrl);
1345
+ ollamaRunning = manager.pingSync?.() || false;
1346
+ } catch {
1347
+ ollamaRunning = false;
1348
+ }
1349
+ }
1350
+
1351
+ // Provider is configured if: has credentials, or is running ollama, or is ollama-cloud
1352
+ const configured = hasCreds || (isOllama && ollamaRunning) || isOllamaCloud;
1353
+
1354
+ // Skip unconfigured providers unless showAll is true
1355
+ if (!showAll && !configured) continue;
1356
+
1357
+ // Add models from this provider
1358
+ for (const [modelId, modelConfig] of Object.entries(providerConfig.models)) {
1359
+ models.push({
1360
+ id: `${providerKey}/${modelId}`,
1361
+ name: (modelConfig as any).name || modelId,
1362
+ provider: providerKey,
1363
+ configured,
1364
+ providerType: providerConfig.type,
1365
+ });
1366
+ }
1367
+ }
1368
+
1369
+ // Also add common aliases for configured providers
1370
+ if (config.models.aliases) {
1371
+ for (const [alias, targetId] of Object.entries(config.models.aliases)) {
1372
+ // Find which provider this alias belongs to
1373
+ for (const [providerKey, providerConfig] of Object.entries(config.models.providers)) {
1374
+ if (providerConfig.models[targetId]) {
1375
+ const hasCreds = this.authManager?.hasCredentials(providerKey) || false;
1376
+ const isOllama = providerConfig.type === 'ollama';
1377
+ const configured = hasCreds || isOllama;
1378
+
1379
+ if (configured && !models.find(m => m.id === alias)) {
1380
+ models.push({
1381
+ id: alias,
1382
+ name: (providerConfig.models[targetId] as any)?.name || alias,
1383
+ provider: providerKey,
1384
+ configured,
1385
+ providerType: providerConfig.type,
1386
+ });
1387
+ }
1388
+ break;
1389
+ }
1390
+ }
1391
+ }
1392
+ }
1393
+
1394
+ return models;
1395
+ }
1396
+
1397
+ /** Interactive model selector with keyboard navigation */
1398
+ private async showModelSelector(): Promise<void> {
1399
+ const models = this.getAvailableModels(false); // Only show configured models
1400
+ const currentModel = this.modelClient.getModel();
1401
+
1402
+ if (models.length === 0) {
1403
+ console.log(C.warning(' No models available for current provider.'));
1404
+ console.log(C.muted(' Current model: ') + C.primary(currentModel));
1405
+ console.log(C.dim(' Usage: /model <model-id>'));
1406
+ return;
1407
+ }
1408
+
1409
+ // Sort models, current model first
1410
+ models.sort((a, b) => {
1411
+ if (a.id === currentModel) return -1;
1412
+ if (b.id === currentModel) return 1;
1413
+ return a.id.localeCompare(b.id);
1414
+ });
1415
+
1416
+ let selectedIndex = models.findIndex(m => m.id === currentModel);
1417
+ if (selectedIndex < 0) selectedIndex = 0;
1418
+
1419
+ // Print header
1420
+ console.log('');
1421
+ console.log(C.brand(` ${BOX.tl}${BOX.h.repeat(50)}${BOX.tr}`));
1422
+ console.log(C.brand(` ${BOX.v} `) + C.primary('Select a model') + C.dim(` (↑↓ navigate, Enter select, Esc cancel)`).padEnd(50) + C.brand(BOX.v));
1423
+ console.log(C.brand(` ${BOX.v} `) + C.muted(`Current: ${currentModel}`).padEnd(50) + C.brand(BOX.v));
1424
+ console.log(C.brand(` ${BOX.ht}${BOX.h.repeat(50)}${BOX.htr}`));
1425
+
1426
+ // Render models - Windows-compatible version with minimal redraw
1427
+ let isFirstRender = true;
1428
+ const renderModels = (selected: number) => {
1429
+ if (isFirstRender) {
1430
+ // First render - draw models only (header already printed above)
1431
+ for (let i = 0; i < models.length; i++) {
1432
+ const m = models[i];
1433
+ const isSelected = i === selected;
1434
+ const isCurrent = m.id === currentModel;
1435
+
1436
+ // Configuration status
1437
+ const statusIcon = m.configured ? '✓' : '⚠';
1438
+ const statusColor = m.configured ? C.success : C.warning;
1439
+
1440
+ let line = ' ' + BOX.v + ' ';
1441
+ if (isSelected) {
1442
+ line += C.brand(BOX.arrowRight + ' ');
1443
+ } else {
1444
+ line += ' ';
1445
+ }
1446
+
1447
+ // Add status icon
1448
+ line += statusColor(statusIcon) + ' ';
1449
+
1450
+ if (isCurrent) {
1451
+ line += isSelected
1452
+ ? C.success(`${m.name}`) + C.dim(` (${m.id})`)
1453
+ : C.success(` ${m.name}`) + C.dim(` (${m.id})`);
1454
+ } else if (isSelected) {
1455
+ line += C.brand(m.name) + C.dim(` (${m.id})`);
1456
+ } else {
1457
+ line += C.muted(m.name) + C.dim(` (${m.id})`);
1458
+ }
1459
+
1460
+ // Add configuration status text for unconfigured models
1461
+ if (!m.configured) {
1462
+ line += C.warning(' [needs setup]');
1463
+ }
1464
+
1465
+ console.log(line.padEnd(57) + C.brand(BOX.v));
1466
+ }
1467
+ console.log(C.brand(` ${BOX.bl}${BOX.h.repeat(50)}${BOX.br}`));
1468
+ isFirstRender = false;
1469
+ } else {
1470
+ // Subsequent renders - only update changed lines
1471
+ // Move cursor up to the start of models section
1472
+ const linesToMove = models.length + 1;
1473
+ process.stdout.write(`\x1b[${linesToMove}A`);
1474
+
1475
+ // Clear each line and redraw
1476
+ for (let i = 0; i < models.length; i++) {
1477
+ process.stdout.write('\x1b[2K\r'); // Clear line
1478
+
1479
+ const m = models[i];
1480
+ const isSelected = i === selected;
1481
+ const isCurrent = m.id === currentModel;
1482
+
1483
+ // Configuration status
1484
+ const statusIcon = m.configured ? '✓' : '⚠';
1485
+ const statusColor = m.configured ? C.success : C.warning;
1486
+
1487
+ let line = ' ' + BOX.v + ' ';
1488
+ if (isSelected) {
1489
+ line += C.brand(BOX.arrowRight + ' ');
1490
+ } else {
1491
+ line += ' ';
1492
+ }
1493
+
1494
+ // Add status icon
1495
+ line += statusColor(statusIcon) + ' ';
1496
+
1497
+ if (isCurrent) {
1498
+ line += isSelected
1499
+ ? C.success(`${m.name}`) + C.dim(` (${m.id})`)
1500
+ : C.success(` ${m.name}`) + C.dim(` (${m.id})`);
1501
+ } else if (isSelected) {
1502
+ line += C.brand(m.name) + C.dim(` (${m.id})`);
1503
+ } else {
1504
+ line += C.muted(m.name) + C.dim(` (${m.id})`);
1505
+ }
1506
+
1507
+ // Add configuration status text for unconfigured models
1508
+ if (!m.configured) {
1509
+ line += C.warning(' [needs setup]');
1510
+ }
1511
+
1512
+ process.stdout.write(line.padEnd(57) + C.brand(BOX.v) + '\n');
1513
+ }
1514
+
1515
+ // Redraw footer
1516
+ process.stdout.write('\x1b[2K\r');
1517
+ console.log(C.brand(` ${BOX.bl}${BOX.h.repeat(50)}${BOX.br}`));
1518
+ }
1519
+ };
1520
+
1521
+ // Initial render - call renderModels once
1522
+ renderModels(selectedIndex);
1523
+
1524
+ // Handle keyboard input
1525
+ return new Promise((resolve) => {
1526
+ // Set stdin to raw mode for key detection
1527
+ const wasRaw = process.stdin.isRaw;
1528
+ if (process.stdin.isTTY) {
1529
+ process.stdin.setRawMode(true);
1530
+ }
1531
+ process.stdin.resume();
1532
+
1533
+ const cleanup = () => {
1534
+ if (process.stdin.isTTY) {
1535
+ process.stdin.setRawMode(wasRaw ?? false);
1536
+ }
1537
+ // Don't pause - let readline continue
1538
+ process.stdin.off('data', onData);
1539
+ };
1540
+
1541
+ const onData = (buffer: Buffer) => {
1542
+ const key = buffer.toString();
1543
+
1544
+ // Up arrow
1545
+ if (key === '\x1b[A' || key === 'k') {
1546
+ selectedIndex = (selectedIndex - 1 + models.length) % models.length;
1547
+ renderModels(selectedIndex);
1548
+ }
1549
+ // Down arrow
1550
+ else if (key === '\x1b[B' || key === 'j') {
1551
+ selectedIndex = (selectedIndex + 1) % models.length;
1552
+ renderModels(selectedIndex);
1553
+ }
1554
+ // Enter
1555
+ else if (key === '\r' || key === '\n') {
1556
+ cleanup();
1557
+ const selected = models[selectedIndex];
1558
+ console.log('');
1559
+
1560
+ // Check if model is configured
1561
+ const providerName = selected.id.split(':')[0];
1562
+ const hasCreds = this.authManager?.hasCredentials(providerName);
1563
+ const isOllama = providerName === 'ollama' || providerName === 'ollama-cloud';
1564
+
1565
+ // Handle model switching with proper error handling
1566
+ const switchModel = async () => {
1567
+ try {
1568
+ if (!hasCreds && !isOllama && this.authManager) {
1569
+ // Model not configured - prompt for setup
1570
+ console.log(C.warning(` Model "${selected.name}" is not configured.`));
1571
+ console.log(C.muted(' Please provide API credentials to use this model.'));
1572
+ console.log('');
1573
+
1574
+ // Prompt for API key
1575
+ const apiKey = await this.promptForApiKey(providerName, providerName);
1576
+
1577
+ if (apiKey) {
1578
+ await this.authManager.setCredentials({ provider: providerName, apiKey });
1579
+ console.log('');
1580
+ console.log(C.success(` ✓ Configuration saved. Switching to ${selected.name}...`));
1581
+ } else {
1582
+ console.log(C.error(' ✗ Configuration cancelled or failed.'));
1583
+ return;
1584
+ }
1585
+ } else if (selected.id === currentModel) {
1586
+ console.log(C.muted(' Model unchanged: ') + C.primary(currentModel));
1587
+ return;
1588
+ }
1589
+
1590
+ // Switch model
1591
+ this.modelClient.updateOptions({ model: selected.id });
1592
+ console.log(C.success(` ✓ Switched to: `) + C.primary(selected.id));
1593
+
1594
+ // Save to global config
1595
+ const config = this.configManager.getConfig();
1596
+ config.core.defaultModel = selected.id;
1597
+ await this.configManager.save(config);
1598
+
1599
+ if (this.sessionId) {
1600
+ const cfg = this.sessionManager.getConfig(this.sessionId);
1601
+ cfg.model = selected.id;
1602
+ }
1603
+ } catch (err) {
1604
+ console.log(C.error(` Error switching model: ${(err as Error).message}`));
1605
+ }
1606
+ };
1607
+
1608
+ // Execute switch and then resolve
1609
+ switchModel().then(() => resolve()).catch(err => {
1610
+ console.log(C.error(` Error: ${(err as Error).message}`));
1611
+ resolve();
1612
+ });
1613
+ }
1614
+ // Escape or Ctrl+C
1615
+ else if (key === '\x1b' || key === '\x03') {
1616
+ cleanup();
1617
+ console.log('');
1618
+ console.log(C.muted(' Cancelled'));
1619
+ resolve();
1620
+ }
1621
+ // Number key for quick select (1-9)
1622
+ else if (key >= '1' && key <= '9') {
1623
+ const num = parseInt(key, 10) - 1;
1624
+ if (num < models.length) {
1625
+ selectedIndex = num;
1626
+ renderModels(selectedIndex);
1627
+ }
1628
+ }
1629
+ };
1630
+
1631
+ process.stdin.on('data', onData);
1632
+ });
1633
+ }
1634
+
1635
+ // ========================================================================
1636
+ // /init — generate NOVA.md project memory file
1637
+ // ========================================================================
1638
+
1639
+ private async handleInitCommand(arg: string): Promise<void> {
1640
+ const targetDir = arg ? path.resolve(this.cwd, arg) : this.cwd;
1641
+ const novaFile = path.join(targetDir, 'NOVA.md');
1642
+
1643
+ if (fs.existsSync(novaFile)) {
1644
+ console.log(C.warning(` NOVA.md already exists at: ${novaFile}`));
1645
+ console.log(C.muted(' Use /init --force to regenerate'));
1646
+ if (!arg.includes('--force')) return;
1647
+ }
1648
+
1649
+ console.log('');
1650
+ console.log(C.brand(' Scanning project structure...'));
1651
+
1652
+ // Gather project info
1653
+ const scanResult = this.scanProjectForInit(targetDir);
1654
+
1655
+ const content = this.generateNovaMd(scanResult, targetDir);
1656
+
1657
+ fs.writeFileSync(novaFile, content, 'utf-8');
1658
+ console.log(C.success(` ✓ NOVA.md created at ${novaFile}`));
1659
+ console.log(C.muted(` This file helps the AI understand your project.`));
1660
+ console.log(C.muted(` Edit it to add custom instructions and context.`));
1661
+ console.log('');
1662
+ console.log(C.dim(' Preview:'));
1663
+ const preview = content.split('\n').slice(0, 20).join('\n');
1664
+ console.log(C.dim(preview));
1665
+ if (content.split('\n').length > 20) console.log(C.dim(' ...'));
1666
+ }
1667
+
1668
+ private scanProjectForInit(dir: string): Record<string, unknown> {
1669
+ const result: Record<string, unknown> = {};
1670
+
1671
+ // Package.json
1672
+ const pkgPath = path.join(dir, 'package.json');
1673
+ if (fs.existsSync(pkgPath)) {
1674
+ try {
1675
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1676
+ result.name = pkg.name;
1677
+ result.version = pkg.version;
1678
+ result.description = pkg.description;
1679
+ result.scripts = pkg.scripts;
1680
+ result.dependencies = Object.keys(pkg.dependencies || {}).slice(0, 20);
1681
+ result.devDependencies = Object.keys(pkg.devDependencies || {}).slice(0, 20);
1682
+ result.packageManager = pkg.packageManager;
1683
+ result.type = pkg.type;
1684
+ } catch { /* skip */ }
1685
+ }
1686
+
1687
+ // Detect project type
1688
+ const indicators = {
1689
+ typescript: fs.existsSync(path.join(dir, 'tsconfig.json')),
1690
+ react: fs.existsSync(path.join(dir, 'src', 'App.tsx')) || fs.existsSync(path.join(dir, 'src', 'App.jsx')),
1691
+ nextjs: fs.existsSync(path.join(dir, 'next.config.js')) || fs.existsSync(path.join(dir, 'next.config.ts')),
1692
+ vite: fs.existsSync(path.join(dir, 'vite.config.ts')) || fs.existsSync(path.join(dir, 'vite.config.js')),
1693
+ monorepo: fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) || fs.existsSync(path.join(dir, 'turbo.json')),
1694
+ python: fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'requirements.txt')),
1695
+ rust: fs.existsSync(path.join(dir, 'Cargo.toml')),
1696
+ go: fs.existsSync(path.join(dir, 'go.mod')),
1697
+ docker: fs.existsSync(path.join(dir, 'Dockerfile')),
1698
+ git: fs.existsSync(path.join(dir, '.git')),
1699
+ };
1700
+ result.indicators = indicators;
1701
+
1702
+ // Top-level structure
1703
+ try {
1704
+ const entries = fs.readdirSync(dir).filter((e) => !e.startsWith('.') && e !== 'node_modules');
1705
+ result.topLevel = entries;
1706
+ } catch { /* skip */ }
1707
+
1708
+ // README
1709
+ const readmePath = path.join(dir, 'README.md');
1710
+ if (fs.existsSync(readmePath)) {
1711
+ try {
1712
+ result.readme = fs.readFileSync(readmePath, 'utf-8').slice(0, 1000);
1713
+ } catch { /* skip */ }
1714
+ }
1715
+
1716
+ // Git remote
1717
+ try {
1718
+ const remote = execSync('git remote get-url origin 2>/dev/null', { cwd: dir, encoding: 'utf-8', timeout: 3000 }).trim();
1719
+ result.gitRemote = remote;
1720
+ } catch { /* skip */ }
1721
+
1722
+ return result;
1723
+ }
1724
+
1725
+ private generateNovaMd(scan: Record<string, unknown>, dir: string): string {
1726
+ const name = scan.name || path.basename(dir);
1727
+ const date = new Date().toISOString().split('T')[0];
1728
+ const indicators = (scan.indicators || {}) as Record<string, boolean>;
1729
+
1730
+ const tech: string[] = [];
1731
+ if (indicators.typescript) tech.push('TypeScript');
1732
+ if (indicators.react) tech.push('React');
1733
+ if (indicators.nextjs) tech.push('Next.js');
1734
+ if (indicators.vite) tech.push('Vite');
1735
+ if (indicators.monorepo) tech.push('Monorepo');
1736
+ if (indicators.python) tech.push('Python');
1737
+ if (indicators.rust) tech.push('Rust');
1738
+ if (indicators.go) tech.push('Go');
1739
+ if (indicators.docker) tech.push('Docker');
1740
+
1741
+ const scripts = scan.scripts as Record<string, string> | undefined;
1742
+ const scriptLines = scripts ? Object.entries(scripts).map(([k, v]) => `- \`${k}\`: ${v}`).join('\n') : '';
1743
+
1744
+ const deps = (scan.dependencies as string[] | undefined) || [];
1745
+ const devDeps = (scan.devDependencies as string[] | undefined) || [];
1746
+
1747
+ return `# NOVA.md — Project Memory
1748
+
1749
+ > Auto-generated on ${date}. Edit this file to customize AI behavior for this project.
1750
+
1751
+ ## Project Overview
1752
+
1753
+ **Name**: ${name}
1754
+ **Version**: ${scan.version || 'unknown'}
1755
+ **Description**: ${scan.description || '(add a description here)'}
1756
+ **Location**: ${dir}
1757
+ ${scan.gitRemote ? `**Repository**: ${scan.gitRemote}` : ''}
1758
+
1759
+ ## Technology Stack
1760
+
1761
+ ${tech.length > 0 ? tech.map((t) => `- ${t}`).join('\n') : '- (detect automatically)'}
1762
+
1763
+ ## Key Commands
1764
+
1765
+ ${scriptLines || '- (add your build/test/dev commands here)'}
1766
+
1767
+ ## Dependencies
1768
+
1769
+ ${deps.length > 0 ? `Main: ${deps.slice(0, 10).join(', ')}` : ''}
1770
+ ${devDeps.length > 0 ? `Dev: ${devDeps.slice(0, 10).join(', ')}` : ''}
1771
+
1772
+ ## Project Structure
1773
+
1774
+ \`\`\`
1775
+ ${((scan.topLevel as string[]) || []).slice(0, 20).join('\n')}
1776
+ \`\`\`
1777
+
1778
+ ## Coding Conventions
1779
+
1780
+ <!-- Add your project-specific conventions here -->
1781
+ - (e.g., Use single quotes for strings)
1782
+ - (e.g., Always add JSDoc comments to exported functions)
1783
+ - (e.g., Test files go in __tests__ directories)
1784
+
1785
+ ## Important Notes for AI
1786
+
1787
+ <!-- Add any special instructions, context, or warnings for the AI assistant -->
1788
+ - Working directory: ${dir}
1789
+ - (e.g., Never commit directly to main)
1790
+ - (e.g., Use pnpm, not npm)
1791
+
1792
+ ## File Reference
1793
+
1794
+ <!-- Add paths to key files the AI should know about -->
1795
+ <!-- Example: @src/types/index.ts — Core type definitions -->
1796
+
1797
+ ---
1798
+ *Edit this file to add project-specific context. The AI reads NOVA.md automatically at the start of each session.*
1799
+ `;
1800
+ }
1801
+
1802
+ // ========================================================================
1803
+ // /memory — manage persistent notes
1804
+ // ========================================================================
1805
+
1806
+ private get memoryFile(): string {
1807
+ return path.join(os.homedir(), '.nova', 'memory.md');
1808
+ }
1809
+
1810
+ private async handleMemoryCommand(arg: string): Promise<void> {
1811
+ const parts = arg.trim().split(/\s+/);
1812
+ const sub = parts[0];
1813
+
1814
+ if (!sub || sub === 'show' || sub === 'list') {
1815
+ // Show memory
1816
+ if (!fs.existsSync(this.memoryFile)) {
1817
+ console.log(C.muted(' No memory file yet. Use /memory add <text> to create entries.'));
1818
+ return;
1819
+ }
1820
+ const content = fs.readFileSync(this.memoryFile, 'utf-8');
1821
+ console.log('');
1822
+ console.log(C.brand(' Nova Memory'));
1823
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
1824
+ content.split('\n').forEach((line) => {
1825
+ if (line.startsWith('## ')) console.log(C.brand(' ' + line));
1826
+ else if (line.startsWith('- ')) console.log(C.muted(' ' + line));
1827
+ else console.log(C.dim(' ' + line));
1828
+ });
1829
+ return;
1830
+ }
1831
+
1832
+ if (sub === 'add') {
1833
+ const text = parts.slice(1).join(' ').trim();
1834
+ if (!text) {
1835
+ console.log(C.warning(' Usage: /memory add <text>'));
1836
+ return;
1837
+ }
1838
+ this.ensureDir(path.dirname(this.memoryFile));
1839
+ const timestamp = new Date().toISOString().split('T')[0];
1840
+ const entry = `- [${timestamp}] ${text}\n`;
1841
+ if (!fs.existsSync(this.memoryFile)) {
1842
+ fs.writeFileSync(this.memoryFile, `# Nova Memory\n\n## Notes\n\n${entry}`, 'utf-8');
1843
+ } else {
1844
+ fs.appendFileSync(this.memoryFile, entry, 'utf-8');
1845
+ }
1846
+ console.log(C.success(` ✓ Memory saved: "${text}"`));
1847
+ return;
1848
+ }
1849
+
1850
+ if (sub === 'clear') {
1851
+ if (fs.existsSync(this.memoryFile)) {
1852
+ fs.writeFileSync(this.memoryFile, '# Nova Memory\n\n', 'utf-8');
1853
+ console.log(C.warning(' Memory cleared.'));
1854
+ }
1855
+ return;
1856
+ }
1857
+
1858
+ if (sub === 'edit') {
1859
+ const editor = process.env.EDITOR || process.env.VISUAL || (process.platform === 'win32' ? 'notepad' : 'nano');
1860
+ this.ensureDir(path.dirname(this.memoryFile));
1861
+ if (!fs.existsSync(this.memoryFile)) {
1862
+ fs.writeFileSync(this.memoryFile, '# Nova Memory\n\n## Notes\n\n', 'utf-8');
1863
+ }
1864
+ console.log(C.muted(` Opening ${this.memoryFile} in ${editor}...`));
1865
+ try {
1866
+ execSync(`${editor} "${this.memoryFile}"`, { stdio: 'inherit' });
1867
+ } catch {
1868
+ console.log(C.muted(` Memory file: ${this.memoryFile}`));
1869
+ }
1870
+ return;
1871
+ }
1872
+
1873
+ console.log(C.muted(' Usage: /memory [show|add <text>|clear|edit]'));
1874
+ }
1875
+
1876
+ // ========================================================================
1877
+ // /history — browse and restore previous sessions
1878
+ // ========================================================================
1879
+
1880
+ private async handleHistoryCommand(arg: string): Promise<void> {
1881
+ const parts = arg.trim().split(/\s+/);
1882
+ const sub = parts[0];
1883
+
1884
+ if (!sub || sub === 'list') {
1885
+ const sessions = this.sessionManager.listPersistedSessions(20);
1886
+ if (sessions.length === 0) {
1887
+ console.log(C.muted(' No saved sessions.'));
1888
+ return;
1889
+ }
1890
+ console.log('');
1891
+ console.log(C.brand(' Session History'));
1892
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
1893
+ sessions.forEach((s, idx) => {
1894
+ const date = new Date(s.updatedAt).toLocaleString();
1895
+ const id = s.id.slice(0, 8);
1896
+ const turns = s.turnCount;
1897
+ const tokens = (s.totalInputTokens + s.totalOutputTokens).toLocaleString();
1898
+ const title = (s.title || 'New session').slice(0, 50);
1899
+ const isCurrent = this.sessionId && s.id === String(this.sessionId);
1900
+ const marker = isCurrent ? C.success(' ← current') : '';
1901
+ console.log(
1902
+ ` ${C.muted(String(idx + 1).padStart(2) + '.')} ${C.primary(title)}${marker}\n` +
1903
+ ` ${C.dim(id + ' ' + date + ' ' + turns + ' turns ' + tokens + ' tok')}`
1904
+ );
1905
+ });
1906
+ console.log('');
1907
+ console.log(C.dim(' /history restore <n> — switch to session n'));
1908
+ console.log(C.dim(' /history delete <n> — delete session n'));
1909
+ return;
1910
+ }
1911
+
1912
+ if (sub === 'restore') {
1913
+ const n = parseInt(parts[1], 10);
1914
+ const sessions = this.sessionManager.listPersistedSessions(20);
1915
+ if (isNaN(n) || n < 1 || n > sessions.length) {
1916
+ console.log(C.warning(` Invalid index. Use /history to see sessions.`));
1917
+ return;
1918
+ }
1919
+ // Save current first
1920
+ if (this.sessionId) this.sessionManager.persist(this.sessionId);
1921
+
1922
+ const target = sessions[n - 1];
1923
+ const restored = this.sessionManager.loadFromDisk(target.id);
1924
+ if (restored) {
1925
+ this.sessionId = restored.id;
1926
+ const msgs = this.sessionManager.getMessages(this.sessionId);
1927
+ console.log(C.success(` ✓ Restored session ${target.id.slice(0, 8)} — ${msgs.length} messages`));
1928
+ console.log(C.muted(` Title: "${target.title}"`));
1929
+ } else {
1930
+ console.log(C.error(' Failed to restore session.'));
1931
+ }
1932
+ return;
1933
+ }
1934
+
1935
+ if (sub === 'delete') {
1936
+ const n = parseInt(parts[1], 10);
1937
+ const sessions = this.sessionManager.listPersistedSessions(20);
1938
+ if (isNaN(n) || n < 1 || n > sessions.length) {
1939
+ console.log(C.warning(` Invalid index.`));
1940
+ return;
1941
+ }
1942
+ const target = sessions[n - 1];
1943
+ const deleted = this.sessionManager.deletePersisted(target.id);
1944
+ if (deleted) {
1945
+ console.log(C.success(` ✓ Deleted session ${target.id.slice(0, 8)}`));
1946
+ } else {
1947
+ console.log(C.error(' Failed to delete session.'));
1948
+ }
1949
+ return;
1950
+ }
1951
+
1952
+ console.log(C.muted(' Usage: /history [list|restore <n>|delete <n>]'));
1953
+ }
1954
+
1955
+ // ========================================================================
1956
+ // /compress — manually trigger context compression
1957
+ // ========================================================================
1958
+
1959
+ private async handleCompressCommand(): Promise<void> {
1960
+ if (!this.sessionId || !this.contextCompressor) {
1961
+ console.log(C.warning(' No active session or context compressor not initialized.'));
1962
+ return;
1963
+ }
1964
+ const msgs = this.sessionManager.getMessages(this.sessionId);
1965
+ const before = msgs.length;
1966
+ console.log(C.info(` Compressing context (${before} messages)...`));
1967
+ // Trigger via a lightweight session flush
1968
+ this.sessionManager.persist(this.sessionId);
1969
+ console.log(C.success(` ✓ Context snapshot saved. Session: ${String(this.sessionId).slice(0, 8)}`));
1970
+ }
1971
+
1972
+ private printHelp(): void {
1973
+ const w = 60;
1974
+ const hr = C.brandDim(BOX.h.repeat(w));
1975
+ const hrThick = C.brand(BOX.hThick.repeat(w));
1976
+ const vl = C.brandDim(BOX.v);
1977
+
1978
+ const cmd = (name: string, desc: string) =>
1979
+ ` ${C.info(name.padEnd(18))} ${C.muted(desc)}`;
1980
+
1981
+ const section = (title: string) =>
1982
+ `\n${vl} ${C.brandLight(BOX.diamond)} ${C.brand(title)}${' '.repeat(w - title.length - 4)}${vl}`;
1983
+
1984
+ console.log('');
1985
+ console.log(C.brand(BOX.tl) + hrThick + C.brand(BOX.tr));
1986
+ console.log(`${vl} ${C.brand.bold('Nova CLI Commands')}${' '.repeat(w - 18)}${vl}`);
1987
+ console.log(C.brand(BOX.ht) + hr + C.brand(BOX.htr));
1988
+
1989
+ // Navigation
1990
+ console.log(section('Navigation'));
1991
+ console.log(`${vl}${cmd('/help', 'Show this help')}${' '.repeat(w - 28)}${vl}`);
1992
+ console.log(`${vl}${cmd('/quit', 'Exit (session auto-saved)')}${' '.repeat(w - 38)}${vl}`);
1993
+ console.log(`${vl}${cmd('/clear', 'Clear conversation & start new')}${' '.repeat(w - 38)}${vl}`);
1994
+
1995
+ // Session
1996
+ console.log(section('Session'));
1997
+ console.log(`${vl}${cmd('/status', 'Show session info & stats')}${' '.repeat(w - 33)}${vl}`);
1998
+ console.log(`${vl}${cmd('/history', 'List previous sessions')}${' '.repeat(w - 30)}${vl}`);
1999
+ console.log(`${vl}${cmd('/history restore', 'Switch to session n')}${' '.repeat(w - 33)}${vl}`);
2000
+ console.log(`${vl}${cmd('/history delete', 'Delete session n')}${' '.repeat(w - 30)}${vl}`);
2001
+
2002
+ // Model
2003
+ console.log(section('Model'));
2004
+ console.log(`${vl}${cmd('/model', 'Show current model')}${' '.repeat(w - 26)}${vl}`);
2005
+ console.log(`${vl}${cmd('/model <id>', 'Switch model')}${' '.repeat(w - 26)}${vl}`);
2006
+
2007
+ // Mode
2008
+ const currentMode = MODE_LABELS[this.mode].label;
2009
+ console.log(section(`Mode ${C.dim('(current: ' + currentMode + ')')}`));
2010
+ console.log(`${vl} ${C.info('/mode'.padEnd(18))} ${C.muted('Cycle:')} ${C.success('AUTO')} ${C.dim('→')} ${C.warning('PLAN')} ${C.dim('→')} ${C.info('ASK')}${' '.repeat(w - 46)}${vl}`);
2011
+ console.log(`${vl} ${C.info('/mode auto'.padEnd(18))} ${C.success('AUTO')} ${C.dim('— full autonomous, no approval')}${' '.repeat(w - 49)}${vl}`);
2012
+ console.log(`${vl} ${C.info('/mode plan'.padEnd(18))} ${C.warning('PLAN')} ${C.dim('— confirm before each tool')}${' '.repeat(w - 47)}${vl}`);
2013
+ console.log(`${vl} ${C.info('/mode ask'.padEnd(18))} ${C.info('ASK')} ${C.dim('— read-only, answer only')}${' '.repeat(w - 45)}${vl}`);
2014
+
2015
+ // Memory
2016
+ console.log(section('Memory'));
2017
+ console.log(`${vl}${cmd('/init', 'Generate NOVA.md project file')}${' '.repeat(w - 36)}${vl}`);
2018
+ console.log(`${vl}${cmd('/memory', 'Show persistent notes')}${' '.repeat(w - 29)}${vl}`);
2019
+ console.log(`${vl}${cmd('/memory add', 'Add a note')}${' '.repeat(w - 22)}${vl}`);
2020
+
2021
+ // Extensions
2022
+ console.log(section('Extensions'));
2023
+ console.log(`${vl}${cmd('/mcp', 'MCP servers & tools')}${' '.repeat(w - 27)}${vl}`);
2024
+ console.log(`${vl}${cmd('/skills', 'Available skills')}${' '.repeat(w - 26)}${vl}`);
2025
+ console.log(`${vl}${cmd('/skills use', 'Inject skill into next msg')}${' '.repeat(w - 36)}${vl}`);
2026
+ console.log(`${vl}${cmd('/theme', 'Switch color theme')}${' '.repeat(w - 26)}${vl}`);
2027
+ console.log(`${vl}${cmd('/checkpoint', 'File snapshots & rollback')}${' '.repeat(w - 35)}${vl}`);
2028
+ console.log(`${vl}${cmd('/image', 'Add image to chat')}${' '.repeat(w - 26)}${vl}`);
2029
+
2030
+ // Ollama
2031
+ console.log(section('Ollama (Local Models)'));
2032
+ console.log(`${vl}${cmd('/ollama', 'Show status & models')}${' '.repeat(w - 29)}${vl}`);
2033
+ console.log(`${vl}${cmd('/ollama pull <n>', 'Download a model')}${' '.repeat(w - 32)}${vl}`);
2034
+ console.log(`${vl}${cmd('/ollama list', 'List installed models')}${' '.repeat(w - 33)}${vl}`);
2035
+
2036
+ // Shortcuts
2037
+ console.log(section('Shortcuts'));
2038
+ console.log(`${vl} ${C.info('@file.ts'.padEnd(18))} ${C.muted('Inject file content')}${' '.repeat(w - 35)}${vl}`);
2039
+ console.log(`${vl} ${C.info('!command'.padEnd(18))} ${C.muted('Run shell command')}${' '.repeat(w - 33)}${vl}`);
2040
+ console.log(`${vl} ${C.info('line\\'.padEnd(18))} ${C.muted('Multi-line input')}${' '.repeat(w - 32)}${vl}`);
2041
+
2042
+ console.log(C.brand(BOX.bl) + hrThick + C.brand(BOX.br));
2043
+ console.log('');
2044
+ }
2045
+
2046
+ // ========================================================================
2047
+ // MCP command handler
2048
+ // ========================================================================
2049
+
2050
+ private async handleMcpCommand(subcommand?: string): Promise<void> {
2051
+ if (!this.mcpManager) {
2052
+ console.log(C.warning(' No MCP manager initialized.'));
2053
+ console.log(C.muted(' Add MCP servers to your config (~/.nova/config.yaml):'));
2054
+ console.log('');
2055
+ console.log(C.dim(' mcp:'));
2056
+ console.log(C.dim(' filesystem:'));
2057
+ console.log(C.dim(' command: npx'));
2058
+ console.log(C.dim(' args: [-y, "@modelcontextprotocol/server-filesystem", /path/to/dir]'));
2059
+ return;
2060
+ }
2061
+
2062
+ const statuses: McpServerStatus[] = this.mcpManager.listServers();
2063
+
2064
+ if (statuses.length === 0) {
2065
+ console.log(C.muted(' No MCP servers configured.'));
2066
+ return;
2067
+ }
2068
+
2069
+ if (!subcommand || subcommand === 'status') {
2070
+ console.log('');
2071
+ console.log(C.brand(' MCP Servers'));
2072
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2073
+
2074
+ for (const s of statuses) {
2075
+ const statusIcon = s.connected ? C.success(BOX.check) : C.error(BOX.cross);
2076
+ const statusStr = s.connected
2077
+ ? C.success('connected')
2078
+ : C.error(`disconnected${s.lastError ? ': ' + s.lastError.slice(0, 40) : ''}`);
2079
+
2080
+ console.log(` ${statusIcon} ${C.primary(s.name.padEnd(20))} ${statusStr}`);
2081
+ if (s.connected) {
2082
+ console.log(
2083
+ C.dim(` ${s.toolCount} tool${s.toolCount !== 1 ? 's' : ''}`) +
2084
+ (s.resourceCount > 0 ? C.dim(`, ${s.resourceCount} resource${s.resourceCount !== 1 ? 's' : ''}`) : '')
2085
+ );
2086
+ }
2087
+ }
2088
+
2089
+ const connected = statuses.filter((s) => s.connected).length;
2090
+ console.log('');
2091
+ console.log(C.muted(` ${connected}/${statuses.length} servers connected`));
2092
+ return;
2093
+ }
2094
+
2095
+ if (subcommand === 'tools') {
2096
+ const allTools = this.toolRegistry.getEnabledToolNames().filter((n) => n.includes('__'));
2097
+ if (allTools.length === 0) {
2098
+ console.log(C.muted(' No MCP tools available.'));
2099
+ return;
2100
+ }
2101
+ console.log('');
2102
+ console.log(C.brand(' MCP Tools'));
2103
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2104
+ for (const t of allTools) {
2105
+ const [ns, toolName] = t.split('__');
2106
+ console.log(` ${C.info(ns.padEnd(16))} ${C.primary(toolName)}`);
2107
+ }
2108
+ console.log('');
2109
+ console.log(C.muted(` ${allTools.length} MCP tool${allTools.length !== 1 ? 's' : ''} available`));
2110
+ return;
2111
+ }
2112
+
2113
+ console.log(C.warning(` Unknown MCP subcommand: ${subcommand}`));
2114
+ console.log(C.muted(' Usage: /mcp [status|tools]'));
2115
+ }
2116
+
2117
+ // ========================================================================
2118
+ // Skills command handler
2119
+ // ========================================================================
2120
+
2121
+ private async handleSkillsCommand(subcommand?: string): Promise<void> {
2122
+ if (!this.skillRegistry) {
2123
+ console.log(C.warning(' Skills system not initialized.'));
2124
+ return;
2125
+ }
2126
+
2127
+ const parts = (subcommand || '').split(/\s+/).filter(Boolean);
2128
+ const cmd = parts[0];
2129
+ const skillName = parts.slice(1).join(' ');
2130
+
2131
+ if (!cmd || cmd === 'list') {
2132
+ const skills = await this.skillRegistry.list();
2133
+ if (skills.length === 0) {
2134
+ console.log(C.muted(' No skills found.'));
2135
+ console.log(C.muted(' Add SKILL.md files to ~/.nova/skills/ to create skills.'));
2136
+ console.log(C.dim(' /skills install superpowers — install popular skills'));
2137
+ return;
2138
+ }
2139
+ console.log('');
2140
+ console.log(C.brand(' Available Skills'));
2141
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2142
+ for (const skill of skills) {
2143
+ const m = skill.metadata;
2144
+ const autoTag = m.autoGenerated ? C.dim(' [auto]') : '';
2145
+ const tags = m.tags.length > 0 ? C.dim(` (${m.tags.slice(0, 3).join(', ')})`) : '';
2146
+ console.log(` ${C.toolName(m.name.padEnd(22))} ${C.muted(m.description.slice(0, 35))}${autoTag}${tags}`);
2147
+ }
2148
+ console.log('');
2149
+ console.log(C.muted(` ${skills.length} skill${skills.length !== 1 ? 's' : ''} available`));
2150
+ console.log(C.dim(' /skills use <name> — inject skill into next message'));
2151
+ console.log(C.dim(' /skills info <name> — show skill details'));
2152
+ console.log(C.dim(' /skills install <repo> — install from GitHub'));
2153
+ return;
2154
+ }
2155
+
2156
+ // Install skills from GitHub
2157
+ if (cmd === 'install') {
2158
+ await this.handleSkillsInstall(skillName);
2159
+ return;
2160
+ }
2161
+
2162
+ if (cmd === 'info' && skillName) {
2163
+ const skill = await this.skillRegistry.get(skillName);
2164
+ if (!skill) { console.log(C.error(` Skill "${skillName}" not found.`)); return; }
2165
+ const m = skill.metadata;
2166
+ console.log('');
2167
+ console.log(C.brand(' ' + m.name));
2168
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2169
+ console.log(C.muted(' Description: ') + C.primary(m.description));
2170
+ console.log(C.muted(' Version: ') + C.primary(m.version));
2171
+ if (m.author) console.log(C.muted(' Author: ') + C.primary(m.author));
2172
+ if (m.tags.length > 0) console.log(C.muted(' Tags: ') + C.primary(m.tags.join(', ')));
2173
+ console.log('');
2174
+ const preview = skill.content.split('\n').slice(0, 10).join('\n');
2175
+ console.log(C.dim(preview));
2176
+ if (skill.content.split('\n').length > 10) console.log(C.dim(' ...'));
2177
+ return;
2178
+ }
2179
+
2180
+ if (cmd === 'use' && skillName) {
2181
+ const skill = await this.skillRegistry.get(skillName);
2182
+ if (!skill) { console.log(C.error(` Skill "${skillName}" not found.`)); return; }
2183
+ this._pendingSkillInject = skill;
2184
+ console.log(C.success(` Skill "${skillName}" will be injected into your next message.`));
2185
+ return;
2186
+ }
2187
+
2188
+ console.log(C.warning(` Unknown skills subcommand.`));
2189
+ console.log(C.muted(' Usage: /skills [list|use <name>|info <name>|install <repo>]'));
2190
+ }
2191
+
2192
+ // ========================================================================
2193
+ // /skills install — Install skills from GitHub
2194
+ // ========================================================================
2195
+
2196
+ private async handleSkillsInstall(repoArg?: string): Promise<void> {
2197
+ if (!repoArg) {
2198
+ console.log('');
2199
+ console.log(C.brand(' Install Skills from GitHub'));
2200
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2201
+ console.log(C.muted(' Install skills from GitHub repositories.'));
2202
+ console.log('');
2203
+ console.log(C.info(' Popular repositories:'));
2204
+ console.log(C.dim(' • superpowers — Agentic skills (TDD, debugging, review)'));
2205
+ console.log(C.dim(' • owner/repo — Any GitHub repository'));
2206
+ console.log('');
2207
+ console.log(C.dim(' Usage:'));
2208
+ console.log(C.primary(' /skills install superpowers'));
2209
+ console.log(C.primary(' /skills install obra/superpowers'));
2210
+ console.log(C.primary(' /skills install https://github.com/owner/repo'));
2211
+ return;
2212
+ }
2213
+
2214
+ // Import installer
2215
+ const { SkillInstaller, POPULAR_SKILL_REPOS } = await import('../core/extensions/SkillInstaller.js');
2216
+ const installer = new SkillInstaller();
2217
+
2218
+ // Resolve shorthand
2219
+ const source = POPULAR_SKILL_REPOS[repoArg]?.url || repoArg;
2220
+
2221
+ console.log(C.muted(` Installing from: ${source}`));
2222
+ console.log('');
2223
+
2224
+ try {
2225
+ const installed = await installer.install({ source, force: false });
2226
+
2227
+ if (installed.length === 0) {
2228
+ console.log(C.warning(' No new skills installed.'));
2229
+ console.log(C.dim(' Use --force to overwrite existing skills.'));
2230
+ return;
2231
+ }
2232
+
2233
+ console.log('');
2234
+ console.log(C.success(` ✓ Installed ${installed.length} skill${installed.length !== 1 ? 's' : ''}:`));
2235
+ for (const skill of installed) {
2236
+ console.log(C.primary(` • ${skill.name}`));
2237
+ }
2238
+ console.log('');
2239
+ console.log(C.dim(' Reload skills with: /skills list'));
2240
+ console.log(C.dim(' Use a skill with: /skills use <name>'));
2241
+
2242
+ // Reinitialize skill registry
2243
+ if (this.skillRegistry) {
2244
+ await this.skillRegistry.initialize();
2245
+ }
2246
+
2247
+ } catch (err) {
2248
+ console.log(C.error(` Failed to install: ${(err as Error).message}`));
2249
+ console.log(C.dim(' Make sure git is installed and you have internet access.'));
2250
+ }
2251
+ }
2252
+
2253
+ // ========================================================================
2254
+ // /theme — Switch color theme
2255
+ // ========================================================================
2256
+
2257
+ private async handleThemeCommand(arg: string): Promise<void> {
2258
+ const themes: Record<string, Record<string, string>> = {
2259
+ dark: {
2260
+ brand: '#7C3AED',
2261
+ brandLight: '#A78BFA',
2262
+ success: '#10B981',
2263
+ warning: '#F59E0B',
2264
+ error: '#EF4444',
2265
+ info: '#3B82F6',
2266
+ accent: '#F472B6',
2267
+ },
2268
+ light: {
2269
+ brand: '#6366F1',
2270
+ brandLight: '#818CF8',
2271
+ success: '#16A34A',
2272
+ warning: '#D97706',
2273
+ error: '#DC2626',
2274
+ info: '#2563EB',
2275
+ accent: '#EC4899',
2276
+ },
2277
+ neon: {
2278
+ brand: '#FF00FF',
2279
+ brandLight: '#FF66FF',
2280
+ success: '#00FF00',
2281
+ warning: '#FFFF00',
2282
+ error: '#FF0000',
2283
+ info: '#00FFFF',
2284
+ accent: '#FF00AA',
2285
+ },
2286
+ ocean: {
2287
+ brand: '#0891B2',
2288
+ brandLight: '#06B6D4',
2289
+ success: '#10B981',
2290
+ warning: '#F59E0B',
2291
+ error: '#EF4444',
2292
+ info: '#0EA5E9',
2293
+ accent: '#8B5CF6',
2294
+ },
2295
+ };
2296
+
2297
+ if (!arg) {
2298
+ // Show current theme and available themes
2299
+ console.log('');
2300
+ console.log(C.brand(' Available Themes'));
2301
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2302
+ Object.keys(themes).forEach((name) => {
2303
+ const isCurrent = name === 'dark'; // Default theme
2304
+ const marker = isCurrent ? C.success('●') : C.dim('○');
2305
+ console.log(` ${marker} ${C.primary(name.padEnd(12))} ${C.dim(name === 'dark' ? '(default)' : '')}`);
2306
+ });
2307
+ console.log('');
2308
+ console.log(C.dim(' /theme <name> — switch theme'));
2309
+ return;
2310
+ }
2311
+
2312
+ const themeName = arg.toLowerCase();
2313
+ if (!themes[themeName]) {
2314
+ console.log(C.error(` Unknown theme: ${arg}`));
2315
+ console.log(C.muted(` Available: ${Object.keys(themes).join(', ')}`));
2316
+ return;
2317
+ }
2318
+
2319
+ // Note: In a real implementation, we would:
2320
+ // 1. Save theme preference to ~/.nova/theme.json
2321
+ // 2. Update the C color object dynamically
2322
+ // 3. Redraw the UI with new colors
2323
+
2324
+ console.log(C.success(` ✓ Theme switched to: ${themeName}`));
2325
+ console.log(C.muted(' Note: Theme will be fully applied after restart'));
2326
+ }
2327
+
2328
+ // ========================================================================
2329
+ // /image — Add image to conversation
2330
+ // ========================================================================
2331
+
2332
+ private async handleImageCommand(arg: string): Promise<void> {
2333
+ if (!arg) {
2334
+ console.log(C.error(' Usage: /image <path-or-url> [description]'));
2335
+ console.log(C.muted(' Example: /image ./screenshot.png "Error message"'));
2336
+ console.log(C.muted(' Example: /image https://example.com/chart.png'));
2337
+ return;
2338
+ }
2339
+
2340
+ const parts = arg.split(/\s+/);
2341
+ const imagePath = parts[0];
2342
+ const description = parts.slice(1).join(' ');
2343
+
2344
+ try {
2345
+ let imageData: string;
2346
+ let mediaType: string;
2347
+
2348
+ // Handle URL
2349
+ if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
2350
+ console.log(C.muted(` Fetching image from URL...`));
2351
+ // URL image support - note: this requires additional implementation
2352
+ // For now, we'll just acknowledge the URL
2353
+ console.log(C.warning(' URL image support is limited in this version'));
2354
+ console.log(C.muted(` Please download the image and use local path instead`));
2355
+ return;
2356
+ }
2357
+ // Handle local file
2358
+ else {
2359
+ const fullPath = require('path').resolve(this.cwd, imagePath);
2360
+ console.log(C.muted(` Reading image: ${fullPath}`));
2361
+
2362
+ const fs = require('node:fs');
2363
+ if (!fs.existsSync(fullPath)) {
2364
+ console.log(C.error(` File not found: ${imagePath}`));
2365
+ return;
2366
+ }
2367
+
2368
+ // Read and encode image
2369
+ const imageBuffer = fs.readFileSync(fullPath);
2370
+ imageData = imageBuffer.toString('base64');
2371
+
2372
+ // Determine media type from extension
2373
+ const ext = require('path').extname(fullPath).toLowerCase();
2374
+ const mimeTypes: Record<string, string> = {
2375
+ '.png': 'image/png',
2376
+ '.jpg': 'image/jpeg',
2377
+ '.jpeg': 'image/jpeg',
2378
+ '.gif': 'image/gif',
2379
+ '.webp': 'image/webp',
2380
+ '.svg': 'image/svg+xml',
2381
+ '.bmp': 'image/bmp',
2382
+ };
2383
+ mediaType = mimeTypes[ext] || 'image/jpeg';
2384
+ }
2385
+
2386
+ // Add image to session
2387
+ if (!this.sessionId) {
2388
+ console.log(C.error(' No active session'));
2389
+ return;
2390
+ }
2391
+
2392
+ // Create image content block
2393
+ const imageContent = {
2394
+ type: 'image' as const,
2395
+ source: {
2396
+ type: 'base64' as const,
2397
+ media_type: mediaType,
2398
+ data: imageData,
2399
+ },
2400
+ };
2401
+
2402
+ // Add to session messages
2403
+ this.sessionManager.addMessage(this.sessionId, 'user', [
2404
+ { type: 'text' as const, text: description || `Image: ${imagePath}` },
2405
+ imageContent,
2406
+ ]);
2407
+
2408
+ console.log(C.success(` ✓ Image added to conversation`));
2409
+ console.log(C.muted(` Path: ${imagePath}`));
2410
+ console.log(C.muted(` Size: ${(imageData.length / 1024).toFixed(1)} KB`));
2411
+ console.log(C.muted(` Type: ${mediaType}`));
2412
+
2413
+ } catch (err) {
2414
+ console.log(C.error(` Failed to add image: ${err.message}`));
2415
+ console.log(C.muted(` Make sure the file is a valid image (PNG, JPG, GIF, etc.)`));
2416
+ }
2417
+ }
2418
+
2419
+ // ========================================================================
2420
+ // /checkpoint — File snapshot and rollback management
2421
+ // ========================================================================
2422
+
2423
+ private async handleCheckpointCommand(arg: string): Promise<void> {
2424
+ const { CheckpointManager } = await import('../core/utils/CheckpointManager.js');
2425
+ const manager = new CheckpointManager(this.cwd, this.config);
2426
+
2427
+ const parts = arg.split(/\s+/).filter(Boolean);
2428
+ const cmd = parts[0];
2429
+ const subArg = parts.slice(1).join(' ');
2430
+
2431
+ if (!cmd || cmd === 'list') {
2432
+ const checkpoints = await manager.list();
2433
+ if (checkpoints.length === 0) {
2434
+ console.log(C.muted(' No checkpoints found.'));
2435
+ console.log(C.muted(' Create one with: /checkpoint create <name> [files...]'));
2436
+ return;
2437
+ }
2438
+
2439
+ console.log('');
2440
+ console.log(C.brand(' Checkpoints'));
2441
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2442
+
2443
+ for (const cp of checkpoints.slice(0, 10)) {
2444
+ const date = new Date(cp.timestamp).toLocaleString();
2445
+ const fileCount = cp.files.length;
2446
+ const idShort = cp.id.slice(0, 8);
2447
+ console.log(` ${C.info(idShort)} ${C.primary(cp.name.padEnd(20))} ${C.dim(date)} ${C.muted(`(${fileCount} files)`)}`);
2448
+ }
2449
+
2450
+ if (checkpoints.length > 10) {
2451
+ console.log(C.dim(` ... and ${checkpoints.length - 10} more`));
2452
+ }
2453
+
2454
+ console.log('');
2455
+ console.log(C.dim(' /checkpoint create <name> [pattern] — create snapshot'));
2456
+ console.log(C.dim(' /checkpoint restore <id> — restore snapshot'));
2457
+ console.log(C.dim(' /checkpoint diff <id> — show differences'));
2458
+ console.log(C.dim(' /checkpoint delete <id> — delete snapshot'));
2459
+ console.log(C.dim(' /checkpoint stats — show statistics'));
2460
+ return;
2461
+ }
2462
+
2463
+ if (cmd === 'create') {
2464
+ if (!subArg) {
2465
+ console.log(C.error(' Usage: /checkpoint create <name> [file-pattern]'));
2466
+ console.log(C.muted(' Example: /checkpoint create "before-refactor" "src/**/*.ts"'));
2467
+ return;
2468
+ }
2469
+
2470
+ const nameMatch = subArg.match(/^"([^"]+)"(?:\s+(.+))?$/);
2471
+ if (!nameMatch) {
2472
+ console.log(C.error(' Invalid format. Use: /checkpoint create "name" [pattern]'));
2473
+ return;
2474
+ }
2475
+
2476
+ const name = nameMatch[1];
2477
+ const pattern = nameMatch[2] || '**/*';
2478
+
2479
+ console.log(C.muted(` Creating checkpoint "${name}"...`));
2480
+
2481
+ try {
2482
+ const checkpoint = await manager.create(name, [pattern], `Created via CLI`);
2483
+ console.log(C.success(` ✓ Checkpoint created: ${checkpoint.id.slice(0, 8)}`));
2484
+ console.log(C.muted(` Files: ${checkpoint.files.length}`));
2485
+ } catch (err) {
2486
+ console.log(C.error(` Failed to create checkpoint: ${err}`));
2487
+ }
2488
+ return;
2489
+ }
2490
+
2491
+ if (cmd === 'restore') {
2492
+ if (!subArg) {
2493
+ console.log(C.error(' Usage: /checkpoint restore <id>'));
2494
+ return;
2495
+ }
2496
+
2497
+ const checkpointId = subArg;
2498
+ const checkpoint = await manager.load(checkpointId);
2499
+ if (!checkpoint) {
2500
+ console.log(C.error(` Checkpoint not found: ${checkpointId}`));
2501
+ return;
2502
+ }
2503
+
2504
+ console.log(C.warning(` ⚠ This will overwrite current files with checkpoint version`));
2505
+ console.log(C.muted(` Checkpoint: ${checkpoint.name}`));
2506
+ console.log(C.muted(` Files: ${checkpoint.files.length}`));
2507
+ console.log(C.muted(` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`));
2508
+
2509
+ // In a real implementation, we'd use ConfirmDialog here
2510
+ const { ConfirmDialog } = await import('../ui/components/ConfirmDialog.js');
2511
+ const dialog = new ConfirmDialog();
2512
+ const confirmed = await dialog.danger('Restore this checkpoint?');
2513
+
2514
+ if (!confirmed) {
2515
+ console.log(C.muted(' Restore cancelled.'));
2516
+ return;
2517
+ }
2518
+
2519
+ try {
2520
+ await manager.restore(checkpointId);
2521
+ console.log(C.success(` ✓ Checkpoint restored successfully`));
2522
+ } catch (err) {
2523
+ console.log(C.error(` Failed to restore checkpoint: ${err}`));
2524
+ }
2525
+ return;
2526
+ }
2527
+
2528
+ if (cmd === 'diff') {
2529
+ if (!subArg) {
2530
+ console.log(C.error(' Usage: /checkpoint diff <id>'));
2531
+ return;
2532
+ }
2533
+
2534
+ try {
2535
+ const differences = await manager.diff(subArg);
2536
+ if (differences.length === 0) {
2537
+ console.log(C.muted(' No differences from checkpoint.'));
2538
+ return;
2539
+ }
2540
+
2541
+ console.log('');
2542
+ console.log(C.brand(' Differences'));
2543
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2544
+
2545
+ for (const diff of differences) {
2546
+ const icon = diff.status === 'modified' ? '◉' : diff.status === 'deleted' ? '✗' : '✚';
2547
+ const color = diff.status === 'modified' ? chalk.yellow : diff.status === 'deleted' ? chalk.red : chalk.green;
2548
+ console.log(` ${color(icon)} ${diff.path} ${chalk.dim(`(${diff.status})`)}`);
2549
+ }
2550
+ } catch (err) {
2551
+ console.log(C.error(` Failed to show diff: ${err}`));
2552
+ }
2553
+ return;
2554
+ }
2555
+
2556
+ if (cmd === 'delete') {
2557
+ if (!subArg) {
2558
+ console.log(C.error(' Usage: /checkpoint delete <id>'));
2559
+ return;
2560
+ }
2561
+
2562
+ const checkpoint = await manager.load(subArg);
2563
+ if (!checkpoint) {
2564
+ console.log(C.error(` Checkpoint not found: ${subArg}`));
2565
+ return;
2566
+ }
2567
+
2568
+ console.log(C.warning(` ⚠ Delete checkpoint "${checkpoint.name}"?`));
2569
+
2570
+ const { ConfirmDialog } = await import('../ui/components/ConfirmDialog.js');
2571
+ const dialog = new ConfirmDialog();
2572
+ const confirmed = await dialog.warning('This cannot be undone');
2573
+
2574
+ if (!confirmed) {
2575
+ console.log(C.muted(' Delete cancelled.'));
2576
+ return;
2577
+ }
2578
+
2579
+ const success = await manager.delete(subArg);
2580
+ if (success) {
2581
+ console.log(C.success(` ✓ Checkpoint deleted`));
2582
+ } else {
2583
+ console.log(C.error(` Failed to delete checkpoint`));
2584
+ }
2585
+ return;
2586
+ }
2587
+
2588
+ if (cmd === 'stats') {
2589
+ const stats = await manager.stats();
2590
+ console.log('');
2591
+ console.log(C.brand(' Checkpoint Statistics'));
2592
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2593
+ console.log(`${C.muted(' Total:')} ${C.primary(stats.totalCheckpoints)}`);
2594
+ console.log(`${C.muted(' Size:')} ${C.primary(this.formatBytes(stats.totalSize))}`);
2595
+ if (stats.oldest) {
2596
+ console.log(`${C.muted(' Oldest:')} ${C.dim(new Date(stats.oldest).toLocaleDateString())}`);
2597
+ }
2598
+ if (stats.newest) {
2599
+ console.log(`${C.muted(' Newest:')} ${C.dim(new Date(stats.newest).toLocaleDateString())}`);
2600
+ }
2601
+ return;
2602
+ }
2603
+
2604
+ console.log(C.error(` Unknown checkpoint command: ${cmd}`));
2605
+ console.log(C.muted(' Usage: /checkpoint [list|create|restore|diff|delete|stats]'));
2606
+ }
2607
+
2608
+ private formatBytes(bytes: number): string {
2609
+ if (bytes === 0) return '0 B';
2610
+ const k = 1024;
2611
+ const sizes = ['B', 'KB', 'MB', 'GB'];
2612
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
2613
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
2614
+ }
2615
+
2616
+ // ========================================================================
2617
+ // /ollama — Ollama status and model management
2618
+ // ========================================================================
2619
+
2620
+ private async handleOllamaCommand(subcommand?: string): Promise<void> {
2621
+ const ollamaCreds = this.authManager?.getCredentials('ollama');
2622
+ const baseUrl = ollamaCreds?.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434';
2623
+ const manager = new OllamaManager(baseUrl);
2624
+ const parts = (subcommand || '').split(/\s+/).filter(Boolean);
2625
+ const cmd = parts[0];
2626
+ const arg = parts.slice(1).join(' ');
2627
+
2628
+ if (!cmd || cmd === 'status') {
2629
+ console.log('');
2630
+ console.log(C.brand(' Ollama Status'));
2631
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2632
+
2633
+ const isRunning = await manager.ping();
2634
+ if (isRunning) {
2635
+ try {
2636
+ const version = await manager.version();
2637
+ console.log(C.success(` ${BOX.check} Running`) + C.dim(` (v${version})`));
2638
+ } catch {
2639
+ console.log(C.success(` ${BOX.check} Running`));
2640
+ }
2641
+ console.log(C.muted(` Host: ${baseUrl}`));
2642
+
2643
+ const models = await manager.listModels();
2644
+ if (models.length > 0) {
2645
+ console.log(C.muted(` Models: ${models.length} installed`));
2646
+ for (const m of models.slice(0, 5)) {
2647
+ const sizeGB = (m.size / 1024 / 1024 / 1024).toFixed(1);
2648
+ console.log(C.dim(` ${BOX.bullet} ${m.name} (${sizeGB} GB)`));
2649
+ }
2650
+ if (models.length > 5) {
2651
+ console.log(C.dim(` ... and ${models.length - 5} more`));
2652
+ }
2653
+ } else {
2654
+ console.log(C.warning(` No models installed`));
2655
+ console.log(C.dim(' Use /ollama pull <model> to download a model'));
2656
+ }
2657
+ } else {
2658
+ console.log(C.error(` ${BOX.crossX} Not running`));
2659
+ console.log(C.muted(` Host: ${baseUrl}`));
2660
+ console.log('');
2661
+ console.log(C.warning(' Start Ollama:'));
2662
+ console.log(C.dim(' ollama serve'));
2663
+ console.log('');
2664
+ console.log(C.warning(' Install Ollama:'));
2665
+ console.log(C.dim(' https://ollama.com'));
2666
+ }
2667
+ console.log('');
2668
+ return;
2669
+ }
2670
+
2671
+ if (cmd === 'list') {
2672
+ if (!(await manager.ping())) {
2673
+ console.log(C.error(` Ollama is not running at ${baseUrl}`));
2674
+ console.log(C.dim(' Start Ollama first: ollama serve'));
2675
+ return;
2676
+ }
2677
+ const models = await manager.listModels();
2678
+ if (models.length === 0) {
2679
+ console.log(C.muted(' No models installed.'));
2680
+ console.log(C.dim(' Pull one with: /ollama pull <model-name>'));
2681
+ return;
2682
+ }
2683
+ console.log('');
2684
+ console.log(C.brand(' Installed Ollama Models'));
2685
+ console.log(C.dim(' ' + BOX.h.repeat(58)));
2686
+ for (const m of models) {
2687
+ const sizeGB = (m.size / 1024 / 1024 / 1024).toFixed(1);
2688
+ const family = m.details?.family || 'unknown';
2689
+ const params = m.details?.parameter_size || '';
2690
+ console.log(` ${C.toolName(m.name)}`);
2691
+ console.log(C.dim(` ${family} ${params} ${sizeGB} GB`));
2692
+ }
2693
+ console.log('');
2694
+ console.log(C.muted(` ${models.length} model(s)`));
2695
+ return;
2696
+ }
2697
+
2698
+ if (cmd === 'pull' && arg) {
2699
+ if (!(await manager.ping())) {
2700
+ console.log(C.error(' Ollama is not running.'));
2701
+ console.log(C.dim(' Start Ollama first: ollama serve'));
2702
+ return;
2703
+ }
2704
+ console.log(C.info(` Pulling model: ${arg}`));
2705
+ console.log(C.dim(' This may take a while...'));
2706
+ try {
2707
+ await manager.pullModel(arg, (status) => {
2708
+ process.stdout.write(`\r ${C.muted(status)} `);
2709
+ });
2710
+ console.log('');
2711
+ console.log(C.success(` ${BOX.check} Model "${arg}" pulled successfully`));
2712
+ console.log(C.dim(` Use: /model ${arg}`));
2713
+ } catch (err) {
2714
+ console.log('');
2715
+ console.log(C.error(` Failed to pull model: ${(err as Error).message}`));
2716
+ }
2717
+ return;
2718
+ }
2719
+
2720
+ if (cmd === 'run' && arg) {
2721
+ console.log(C.info(` Running model: ${arg}`));
2722
+ console.log(C.dim(' Note: Use "ollama run" in terminal for interactive session'));
2723
+ console.log(C.dim(' Switching model for Nova CLI...'));
2724
+ try {
2725
+ // Just switch to the model instead of running interactively
2726
+ this.modelClient.updateOptions({ model: arg });
2727
+ console.log(C.success(` ✓ Switched to Ollama model: ${arg}`));
2728
+ // Save to config
2729
+ const config = this.configManager.getConfig();
2730
+ config.core.defaultModel = arg;
2731
+ await this.configManager.save(config);
2732
+ } catch (err) {
2733
+ console.log(C.error(` Failed to switch model: ${(err as Error).message}`));
2734
+ }
2735
+ return;
2736
+ }
2737
+
2738
+ // Unknown subcommand - show help
2739
+ console.log(C.warning(` Unknown subcommand: ${cmd}`));
2740
+ console.log('');
2741
+ console.log(C.muted(' Usage: /ollama [status|list|pull <model>]'));
2742
+ console.log(C.dim(' /ollama — show status and installed models'));
2743
+ console.log(C.dim(' /ollama list — list all installed models'));
2744
+ console.log(C.dim(' /ollama pull <n> — download a model'));
2745
+ }
2746
+
2747
+ // ========================================================================
2748
+ // Approval handler
2749
+ // ========================================================================
2750
+
2751
+ private async handleApproval(request: ApprovalRequest): Promise<ApprovalResponse> {
2752
+ const effectiveMode = this.getEffectiveApprovalMode();
2753
+
2754
+ if (effectiveMode === 'yolo' || effectiveMode === 'accepting_edits') {
2755
+ return { requestId: request.id, approved: true };
2756
+ }
2757
+
2758
+ this.stopSpinner();
2759
+
2760
+ return new Promise((resolve) => {
2761
+ console.log('');
2762
+ console.log(C.warning.bold(' ⚠ Approval Required'));
2763
+ console.log(C.muted(' Tool: ') + C.toolName(request.toolName));
2764
+ console.log(C.muted(' Risk: ') + (
2765
+ request.risk === 'critical' ? C.error(request.risk) :
2766
+ request.risk === 'high' ? C.warning(request.risk) :
2767
+ C.muted(request.risk)
2768
+ ));
2769
+ if (request.description) {
2770
+ const desc = request.description.replace(`Tool "${request.toolName}" with input: `, '');
2771
+ const preview = desc.slice(0, 80);
2772
+ console.log(C.muted(' Input: ') + C.dim(preview));
2773
+ }
2774
+ console.log('');
2775
+
2776
+ this.rl?.question(C.warning(' Allow? [y/N/a(ll)] '), (answer) => {
2777
+ const a = answer.trim().toLowerCase();
2778
+ if (a === 'a' || a === 'all') {
2779
+ // Switch to yolo for remainder of this task
2780
+ this.mode = 'auto';
2781
+ console.log(C.success(' Auto-approved for this task.'));
2782
+ resolve({ requestId: request.id, approved: true });
2783
+ } else {
2784
+ const approved = a === 'y' || a === 'yes';
2785
+ if (!approved) console.log(C.error(' Denied.'));
2786
+ resolve({ requestId: request.id, approved });
2787
+ }
2788
+ });
2789
+ });
2790
+ }
2791
+
2792
+ // ========================================================================
2793
+ // Helpers
2794
+ // ========================================================================
2795
+
2796
+ private getModePrefix(): string {
2797
+ switch (this.mode) {
2798
+ case 'plan':
2799
+ return '[PLAN MODE] First analyze and create a step-by-step plan. Wait for confirmation before executing.';
2800
+ case 'ask':
2801
+ return '[ASK MODE] Only answer questions. Do NOT modify files or execute commands.';
2802
+ default:
2803
+ return '';
2804
+ }
2805
+ }
2806
+
2807
+ private getEffectiveApprovalMode(): string {
2808
+ return MODE_LABELS[this.mode].approvalMode;
2809
+ }
2810
+
2811
+ private createInitialSession(): SessionId {
2812
+ const session = this.sessionManager.create({
2813
+ workingDirectory: this.cwd,
2814
+ model: this.modelClient.getModel(),
2815
+ maxTokens: this.config.core.maxTokens,
2816
+ temperature: this.config.core.temperature,
2817
+ approvalMode: this.getEffectiveApprovalMode() as any,
2818
+ streaming: true,
2819
+ maxTurns: this.config.core.maxTurns,
2820
+ });
2821
+ return session.id;
2822
+ }
2823
+
2824
+ private getTimeStr(): string {
2825
+ return new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
2826
+ }
2827
+
2828
+ private ensureDir(dirPath: string): void {
2829
+ if (!fs.existsSync(dirPath)) {
2830
+ fs.mkdirSync(dirPath, { recursive: true });
2831
+ }
2832
+ }
2833
+ }