nova-terminal-assistant 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nova-terminal-assistant might be problematic. Click here for more details.
- package/README.md +358 -0
- package/bin/nova +38 -0
- package/bin/nova.js +12 -0
- package/package.json +67 -0
- package/src/cli/commands/SmartCompletion.ts +458 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/startup/IFlowRepl.ts +212 -0
- package/src/cli/startup/InkBasedRepl.ts +1056 -0
- package/src/cli/startup/InteractiveRepl.ts +2833 -0
- package/src/cli/startup/NovaApp.ts +1861 -0
- package/src/cli/startup/index.ts +4 -0
- package/src/cli/startup/parseArgs.ts +293 -0
- package/src/cli/test-modules.ts +27 -0
- package/src/cli/ui/IFlowDropdown.ts +425 -0
- package/src/cli/ui/ModernReplUI.ts +276 -0
- package/src/cli/ui/SimpleSelector2.ts +215 -0
- package/src/cli/ui/components/ConfirmDialog.ts +176 -0
- package/src/cli/ui/components/ErrorPanel.ts +364 -0
- package/src/cli/ui/components/InkAppRunner.tsx +67 -0
- package/src/cli/ui/components/InkComponents.tsx +613 -0
- package/src/cli/ui/components/NovaInkApp.tsx +312 -0
- package/src/cli/ui/components/ProgressBar.ts +177 -0
- package/src/cli/ui/components/ProgressIndicator.ts +298 -0
- package/src/cli/ui/components/QuickActions.ts +396 -0
- package/src/cli/ui/components/SimpleErrorPanel.ts +231 -0
- package/src/cli/ui/components/StatusBar.ts +194 -0
- package/src/cli/ui/components/ThinkingBlockRenderer.ts +401 -0
- package/src/cli/ui/components/index.ts +27 -0
- package/src/cli/ui/ink-prototype.tsx +347 -0
- package/src/cli/utils/CliUI.ts +336 -0
- package/src/cli/utils/CompletionHelper.ts +388 -0
- package/src/cli/utils/EnhancedCompleter.test.ts +226 -0
- package/src/cli/utils/EnhancedCompleter.ts +513 -0
- package/src/cli/utils/ErrorEnhancer.ts +429 -0
- package/src/cli/utils/OutputFormatter.ts +193 -0
- package/src/cli/utils/index.ts +9 -0
- package/src/core/agents/AgentOrchestrator.ts +515 -0
- package/src/core/agents/index.ts +17 -0
- package/src/core/audit/AuditLogger.ts +509 -0
- package/src/core/audit/index.ts +11 -0
- package/src/core/auth/AuthManager.d.ts.map +1 -0
- package/src/core/auth/AuthManager.ts +138 -0
- package/src/core/auth/index.d.ts.map +1 -0
- package/src/core/auth/index.ts +2 -0
- package/src/core/config/ConfigManager.d.ts.map +1 -0
- package/src/core/config/ConfigManager.test.ts +183 -0
- package/src/core/config/ConfigManager.ts +1219 -0
- package/src/core/config/index.d.ts.map +1 -0
- package/src/core/config/index.ts +1 -0
- package/src/core/context/ContextBuilder.d.ts.map +1 -0
- package/src/core/context/ContextBuilder.ts +171 -0
- package/src/core/context/ContextCompressor.d.ts.map +1 -0
- package/src/core/context/ContextCompressor.ts +642 -0
- package/src/core/context/LayeredMemoryManager.ts +657 -0
- package/src/core/context/MemoryDiscovery.d.ts.map +1 -0
- package/src/core/context/MemoryDiscovery.ts +175 -0
- package/src/core/context/defaultSystemPrompt.d.ts.map +1 -0
- package/src/core/context/defaultSystemPrompt.ts +35 -0
- package/src/core/context/index.d.ts.map +1 -0
- package/src/core/context/index.ts +22 -0
- package/src/core/extensions/SkillGenerator.ts +421 -0
- package/src/core/extensions/SkillInstaller.d.ts.map +1 -0
- package/src/core/extensions/SkillInstaller.ts +257 -0
- package/src/core/extensions/SkillRegistry.d.ts.map +1 -0
- package/src/core/extensions/SkillRegistry.ts +361 -0
- package/src/core/extensions/SkillValidator.ts +525 -0
- package/src/core/extensions/index.ts +15 -0
- package/src/core/index.d.ts.map +1 -0
- package/src/core/index.ts +42 -0
- package/src/core/mcp/McpManager.d.ts.map +1 -0
- package/src/core/mcp/McpManager.ts +632 -0
- package/src/core/mcp/index.d.ts.map +1 -0
- package/src/core/mcp/index.ts +2 -0
- package/src/core/model/ModelClient.d.ts.map +1 -0
- package/src/core/model/ModelClient.ts +217 -0
- package/src/core/model/ModelConnectionTester.ts +363 -0
- package/src/core/model/ModelValidator.ts +348 -0
- package/src/core/model/index.d.ts.map +1 -0
- package/src/core/model/index.ts +6 -0
- package/src/core/model/providers/AnthropicProvider.d.ts.map +1 -0
- package/src/core/model/providers/AnthropicProvider.ts +279 -0
- package/src/core/model/providers/CodingPlanProvider.d.ts.map +1 -0
- package/src/core/model/providers/CodingPlanProvider.ts +210 -0
- package/src/core/model/providers/OllamaCloudProvider.d.ts.map +1 -0
- package/src/core/model/providers/OllamaCloudProvider.ts +405 -0
- package/src/core/model/providers/OllamaManager.d.ts.map +1 -0
- package/src/core/model/providers/OllamaManager.ts +201 -0
- package/src/core/model/providers/OllamaProvider.d.ts.map +1 -0
- package/src/core/model/providers/OllamaProvider.ts +73 -0
- package/src/core/model/providers/OpenAICompatibleProvider.d.ts.map +1 -0
- package/src/core/model/providers/OpenAICompatibleProvider.ts +327 -0
- package/src/core/model/providers/OpenAIProvider.d.ts.map +1 -0
- package/src/core/model/providers/OpenAIProvider.ts +29 -0
- package/src/core/model/providers/index.d.ts.map +1 -0
- package/src/core/model/providers/index.ts +12 -0
- package/src/core/model/types.d.ts.map +1 -0
- package/src/core/model/types.ts +77 -0
- package/src/core/security/ApprovalManager.d.ts.map +1 -0
- package/src/core/security/ApprovalManager.ts +174 -0
- package/src/core/security/FileFilter.d.ts.map +1 -0
- package/src/core/security/FileFilter.ts +141 -0
- package/src/core/security/HookExecutor.d.ts.map +1 -0
- package/src/core/security/HookExecutor.ts +178 -0
- package/src/core/security/SandboxExecutor.ts +447 -0
- package/src/core/security/index.d.ts.map +1 -0
- package/src/core/security/index.ts +8 -0
- package/src/core/session/AgentLoop.d.ts.map +1 -0
- package/src/core/session/AgentLoop.ts +501 -0
- package/src/core/session/SessionManager.d.ts.map +1 -0
- package/src/core/session/SessionManager.test.ts +183 -0
- package/src/core/session/SessionManager.ts +460 -0
- package/src/core/session/index.d.ts.map +1 -0
- package/src/core/session/index.ts +3 -0
- package/src/core/telemetry/Telemetry.d.ts.map +1 -0
- package/src/core/telemetry/Telemetry.ts +90 -0
- package/src/core/telemetry/TelemetryService.ts +531 -0
- package/src/core/telemetry/index.d.ts.map +1 -0
- package/src/core/telemetry/index.ts +12 -0
- package/src/core/testing/AutoFixer.ts +385 -0
- package/src/core/testing/ErrorAnalyzer.ts +499 -0
- package/src/core/testing/TestRunner.ts +265 -0
- package/src/core/testing/agent-cli-tests.ts +538 -0
- package/src/core/testing/index.ts +11 -0
- package/src/core/tools/ToolRegistry.d.ts.map +1 -0
- package/src/core/tools/ToolRegistry.test.ts +206 -0
- package/src/core/tools/ToolRegistry.ts +260 -0
- package/src/core/tools/impl/EditFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/EditFileTool.ts +97 -0
- package/src/core/tools/impl/ListDirectoryTool.d.ts.map +1 -0
- package/src/core/tools/impl/ListDirectoryTool.ts +142 -0
- package/src/core/tools/impl/MemoryTool.d.ts.map +1 -0
- package/src/core/tools/impl/MemoryTool.ts +102 -0
- package/src/core/tools/impl/ReadFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/ReadFileTool.ts +58 -0
- package/src/core/tools/impl/SearchContentTool.d.ts.map +1 -0
- package/src/core/tools/impl/SearchContentTool.ts +94 -0
- package/src/core/tools/impl/SearchFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/SearchFileTool.ts +61 -0
- package/src/core/tools/impl/ShellTool.d.ts.map +1 -0
- package/src/core/tools/impl/ShellTool.ts +118 -0
- package/src/core/tools/impl/TaskTool.d.ts.map +1 -0
- package/src/core/tools/impl/TaskTool.ts +207 -0
- package/src/core/tools/impl/TodoTool.d.ts.map +1 -0
- package/src/core/tools/impl/TodoTool.ts +122 -0
- package/src/core/tools/impl/WebFetchTool.d.ts.map +1 -0
- package/src/core/tools/impl/WebFetchTool.ts +103 -0
- package/src/core/tools/impl/WebSearchTool.d.ts.map +1 -0
- package/src/core/tools/impl/WebSearchTool.ts +89 -0
- package/src/core/tools/impl/WriteFileTool.d.ts.map +1 -0
- package/src/core/tools/impl/WriteFileTool.ts +49 -0
- package/src/core/tools/impl/index.d.ts.map +1 -0
- package/src/core/tools/impl/index.ts +16 -0
- package/src/core/tools/index.d.ts.map +1 -0
- package/src/core/tools/index.ts +7 -0
- package/src/core/tools/schemas/execution.d.ts.map +1 -0
- package/src/core/tools/schemas/execution.ts +42 -0
- package/src/core/tools/schemas/file.d.ts.map +1 -0
- package/src/core/tools/schemas/file.ts +119 -0
- package/src/core/tools/schemas/index.d.ts.map +1 -0
- package/src/core/tools/schemas/index.ts +11 -0
- package/src/core/tools/schemas/memory.d.ts.map +1 -0
- package/src/core/tools/schemas/memory.ts +52 -0
- package/src/core/tools/schemas/orchestration.d.ts.map +1 -0
- package/src/core/tools/schemas/orchestration.ts +44 -0
- package/src/core/tools/schemas/search.d.ts.map +1 -0
- package/src/core/tools/schemas/search.ts +112 -0
- package/src/core/tools/schemas/todo.d.ts.map +1 -0
- package/src/core/tools/schemas/todo.ts +32 -0
- package/src/core/tools/schemas/web.d.ts.map +1 -0
- package/src/core/tools/schemas/web.ts +86 -0
- package/src/core/types/config.d.ts.map +1 -0
- package/src/core/types/config.ts +200 -0
- package/src/core/types/errors.d.ts.map +1 -0
- package/src/core/types/errors.ts +204 -0
- package/src/core/types/index.d.ts.map +1 -0
- package/src/core/types/index.ts +8 -0
- package/src/core/types/session.d.ts.map +1 -0
- package/src/core/types/session.ts +216 -0
- package/src/core/types/tools.d.ts.map +1 -0
- package/src/core/types/tools.ts +157 -0
- package/src/core/utils/CheckpointManager.d.ts.map +1 -0
- package/src/core/utils/CheckpointManager.ts +327 -0
- package/src/core/utils/Logger.d.ts.map +1 -0
- package/src/core/utils/Logger.ts +98 -0
- package/src/core/utils/RetryManager.ts +471 -0
- package/src/core/utils/TokenCounter.d.ts.map +1 -0
- package/src/core/utils/TokenCounter.ts +414 -0
- package/src/core/utils/VectorMemoryStore.ts +440 -0
- package/src/core/utils/helpers.d.ts.map +1 -0
- package/src/core/utils/helpers.ts +89 -0
- package/src/core/utils/index.d.ts.map +1 -0
- package/src/core/utils/index.ts +19 -0
|
@@ -0,0 +1,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
|
+
}
|