fixo-cli 1.0.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.
- package/LICENSE +201 -0
- package/README.md +530 -0
- package/dist/agent/agent-client.d.ts +108 -0
- package/dist/agent/agent-client.d.ts.map +1 -0
- package/dist/agent/agent-client.js +1247 -0
- package/dist/agent/agent-client.js.map +1 -0
- package/dist/agent/agent-pool.d.ts +20 -0
- package/dist/agent/agent-pool.d.ts.map +1 -0
- package/dist/agent/agent-pool.js +217 -0
- package/dist/agent/agent-pool.js.map +1 -0
- package/dist/agent/background-awareness.d.ts +55 -0
- package/dist/agent/background-awareness.d.ts.map +1 -0
- package/dist/agent/background-awareness.js +104 -0
- package/dist/agent/background-awareness.js.map +1 -0
- package/dist/agent/command-parser.d.ts +33 -0
- package/dist/agent/command-parser.d.ts.map +1 -0
- package/dist/agent/command-parser.js +120 -0
- package/dist/agent/command-parser.js.map +1 -0
- package/dist/agent/context-budget.d.ts +91 -0
- package/dist/agent/context-budget.d.ts.map +1 -0
- package/dist/agent/context-budget.js +219 -0
- package/dist/agent/context-budget.js.map +1 -0
- package/dist/agent/conversation.d.ts +190 -0
- package/dist/agent/conversation.d.ts.map +1 -0
- package/dist/agent/conversation.js +547 -0
- package/dist/agent/conversation.js.map +1 -0
- package/dist/agent/hooks.d.ts +72 -0
- package/dist/agent/hooks.d.ts.map +1 -0
- package/dist/agent/hooks.js +214 -0
- package/dist/agent/hooks.js.map +1 -0
- package/dist/agent/mcp-bridge.d.ts +13 -0
- package/dist/agent/mcp-bridge.d.ts.map +1 -0
- package/dist/agent/mcp-bridge.js +86 -0
- package/dist/agent/mcp-bridge.js.map +1 -0
- package/dist/agent/mcp-client.d.ts +24 -0
- package/dist/agent/mcp-client.d.ts.map +1 -0
- package/dist/agent/mcp-client.js +146 -0
- package/dist/agent/mcp-client.js.map +1 -0
- package/dist/agent/mcp-manager.d.ts +13 -0
- package/dist/agent/mcp-manager.d.ts.map +1 -0
- package/dist/agent/mcp-manager.js +84 -0
- package/dist/agent/mcp-manager.js.map +1 -0
- package/dist/agent/mcp-registry.d.ts +45 -0
- package/dist/agent/mcp-registry.d.ts.map +1 -0
- package/dist/agent/mcp-registry.js +98 -0
- package/dist/agent/mcp-registry.js.map +1 -0
- package/dist/agent/orchestrator.d.ts +14 -0
- package/dist/agent/orchestrator.d.ts.map +1 -0
- package/dist/agent/orchestrator.js +118 -0
- package/dist/agent/orchestrator.js.map +1 -0
- package/dist/agent/parser-adapter.d.ts +120 -0
- package/dist/agent/parser-adapter.d.ts.map +1 -0
- package/dist/agent/parser-adapter.js +265 -0
- package/dist/agent/parser-adapter.js.map +1 -0
- package/dist/agent/parsers/imports.d.ts +11 -0
- package/dist/agent/parsers/imports.d.ts.map +1 -0
- package/dist/agent/parsers/imports.js +94 -0
- package/dist/agent/parsers/imports.js.map +1 -0
- package/dist/agent/parsers/shell.d.ts +23 -0
- package/dist/agent/parsers/shell.d.ts.map +1 -0
- package/dist/agent/parsers/shell.js +200 -0
- package/dist/agent/parsers/shell.js.map +1 -0
- package/dist/agent/parsers/symbols.d.ts +17 -0
- package/dist/agent/parsers/symbols.d.ts.map +1 -0
- package/dist/agent/parsers/symbols.js +103 -0
- package/dist/agent/parsers/symbols.js.map +1 -0
- package/dist/agent/permissions.d.ts +65 -0
- package/dist/agent/permissions.d.ts.map +1 -0
- package/dist/agent/permissions.js +219 -0
- package/dist/agent/permissions.js.map +1 -0
- package/dist/agent/predictive-gate.d.ts +69 -0
- package/dist/agent/predictive-gate.d.ts.map +1 -0
- package/dist/agent/predictive-gate.js +128 -0
- package/dist/agent/predictive-gate.js.map +1 -0
- package/dist/agent/provider-cooldown.d.ts +144 -0
- package/dist/agent/provider-cooldown.d.ts.map +1 -0
- package/dist/agent/provider-cooldown.js +300 -0
- package/dist/agent/provider-cooldown.js.map +1 -0
- package/dist/agent/providers-manager.d.ts +109 -0
- package/dist/agent/providers-manager.d.ts.map +1 -0
- package/dist/agent/providers-manager.js +464 -0
- package/dist/agent/providers-manager.js.map +1 -0
- package/dist/agent/repo-map.d.ts +6 -0
- package/dist/agent/repo-map.d.ts.map +1 -0
- package/dist/agent/repo-map.js +221 -0
- package/dist/agent/repo-map.js.map +1 -0
- package/dist/agent/retry.d.ts +103 -0
- package/dist/agent/retry.d.ts.map +1 -0
- package/dist/agent/retry.js +276 -0
- package/dist/agent/retry.js.map +1 -0
- package/dist/agent/search/index.d.ts +61 -0
- package/dist/agent/search/index.d.ts.map +1 -0
- package/dist/agent/search/index.js +314 -0
- package/dist/agent/search/index.js.map +1 -0
- package/dist/agent/single-agent.d.ts +76 -0
- package/dist/agent/single-agent.d.ts.map +1 -0
- package/dist/agent/single-agent.js +697 -0
- package/dist/agent/single-agent.js.map +1 -0
- package/dist/agent/skills.d.ts +22 -0
- package/dist/agent/skills.d.ts.map +1 -0
- package/dist/agent/skills.js +139 -0
- package/dist/agent/skills.js.map +1 -0
- package/dist/agent/stream-glue.d.ts +85 -0
- package/dist/agent/stream-glue.d.ts.map +1 -0
- package/dist/agent/stream-glue.js +120 -0
- package/dist/agent/stream-glue.js.map +1 -0
- package/dist/agent/subagent.d.ts +72 -0
- package/dist/agent/subagent.d.ts.map +1 -0
- package/dist/agent/subagent.js +193 -0
- package/dist/agent/subagent.js.map +1 -0
- package/dist/agent/telemetry.d.ts +192 -0
- package/dist/agent/telemetry.d.ts.map +1 -0
- package/dist/agent/telemetry.js +400 -0
- package/dist/agent/telemetry.js.map +1 -0
- package/dist/agent/tokenizer.d.ts +42 -0
- package/dist/agent/tokenizer.d.ts.map +1 -0
- package/dist/agent/tokenizer.js +107 -0
- package/dist/agent/tokenizer.js.map +1 -0
- package/dist/agent/tool-executor.d.ts +289 -0
- package/dist/agent/tool-executor.d.ts.map +1 -0
- package/dist/agent/tool-executor.js +2519 -0
- package/dist/agent/tool-executor.js.map +1 -0
- package/dist/agent/web-impl.d.ts +2 -0
- package/dist/agent/web-impl.d.ts.map +1 -0
- package/dist/agent/web-impl.js +34 -0
- package/dist/agent/web-impl.js.map +1 -0
- package/dist/agent/web.d.ts +8 -0
- package/dist/agent/web.d.ts.map +1 -0
- package/dist/agent/web.js +8 -0
- package/dist/agent/web.js.map +1 -0
- package/dist/agent/worker-agent.d.ts +27 -0
- package/dist/agent/worker-agent.d.ts.map +1 -0
- package/dist/agent/worker-agent.js +503 -0
- package/dist/agent/worker-agent.js.map +1 -0
- package/dist/config.d.ts +162 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +138 -0
- package/dist/config.js.map +1 -0
- package/dist/context/fixo-md-watcher.d.ts +42 -0
- package/dist/context/fixo-md-watcher.d.ts.map +1 -0
- package/dist/context/fixo-md-watcher.js +126 -0
- package/dist/context/fixo-md-watcher.js.map +1 -0
- package/dist/context/fixo-md.d.ts +50 -0
- package/dist/context/fixo-md.d.ts.map +1 -0
- package/dist/context/fixo-md.js +118 -0
- package/dist/context/fixo-md.js.map +1 -0
- package/dist/context/todo.d.ts +65 -0
- package/dist/context/todo.d.ts.map +1 -0
- package/dist/context/todo.js +194 -0
- package/dist/context/todo.js.map +1 -0
- package/dist/git/git-manager.d.ts +33 -0
- package/dist/git/git-manager.d.ts.map +1 -0
- package/dist/git/git-manager.js +293 -0
- package/dist/git/git-manager.js.map +1 -0
- package/dist/git/git-ops.d.ts +10 -0
- package/dist/git/git-ops.d.ts.map +1 -0
- package/dist/git/git-ops.js +131 -0
- package/dist/git/git-ops.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +352 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer.d.ts +30 -0
- package/dist/indexer.d.ts.map +1 -0
- package/dist/indexer.js +273 -0
- package/dist/indexer.js.map +1 -0
- package/dist/lsp/lsp-client.d.ts +24 -0
- package/dist/lsp/lsp-client.d.ts.map +1 -0
- package/dist/lsp/lsp-client.js +205 -0
- package/dist/lsp/lsp-client.js.map +1 -0
- package/dist/lsp/lsp-manager.d.ts +17 -0
- package/dist/lsp/lsp-manager.d.ts.map +1 -0
- package/dist/lsp/lsp-manager.js +154 -0
- package/dist/lsp/lsp-manager.js.map +1 -0
- package/dist/lsp/lsp-pre-save.d.ts +137 -0
- package/dist/lsp/lsp-pre-save.d.ts.map +1 -0
- package/dist/lsp/lsp-pre-save.js +245 -0
- package/dist/lsp/lsp-pre-save.js.map +1 -0
- package/dist/lsp/syntax-fallback.d.ts +83 -0
- package/dist/lsp/syntax-fallback.d.ts.map +1 -0
- package/dist/lsp/syntax-fallback.js +275 -0
- package/dist/lsp/syntax-fallback.js.map +1 -0
- package/dist/model-outcomes.d.ts +12 -0
- package/dist/model-outcomes.d.ts.map +1 -0
- package/dist/model-outcomes.js +46 -0
- package/dist/model-outcomes.js.map +1 -0
- package/dist/planner.d.ts +32 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +163 -0
- package/dist/planner.js.map +1 -0
- package/dist/project-memory.d.ts +29 -0
- package/dist/project-memory.d.ts.map +1 -0
- package/dist/project-memory.js +349 -0
- package/dist/project-memory.js.map +1 -0
- package/dist/review.d.ts +2 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +61 -0
- package/dist/review.js.map +1 -0
- package/dist/runtime/background-jobs.d.ts +97 -0
- package/dist/runtime/background-jobs.d.ts.map +1 -0
- package/dist/runtime/background-jobs.js +331 -0
- package/dist/runtime/background-jobs.js.map +1 -0
- package/dist/runtime/credential-vault.d.ts +124 -0
- package/dist/runtime/credential-vault.d.ts.map +1 -0
- package/dist/runtime/credential-vault.js +184 -0
- package/dist/runtime/credential-vault.js.map +1 -0
- package/dist/runtime/loop-trap.d.ts +197 -0
- package/dist/runtime/loop-trap.d.ts.map +1 -0
- package/dist/runtime/loop-trap.js +420 -0
- package/dist/runtime/loop-trap.js.map +1 -0
- package/dist/runtime/policy.d.ts +15 -0
- package/dist/runtime/policy.d.ts.map +1 -0
- package/dist/runtime/policy.js +60 -0
- package/dist/runtime/policy.js.map +1 -0
- package/dist/runtime/redaction.d.ts +66 -0
- package/dist/runtime/redaction.d.ts.map +1 -0
- package/dist/runtime/redaction.js +155 -0
- package/dist/runtime/redaction.js.map +1 -0
- package/dist/runtime/session-snapshots.d.ts +76 -0
- package/dist/runtime/session-snapshots.d.ts.map +1 -0
- package/dist/runtime/session-snapshots.js +166 -0
- package/dist/runtime/session-snapshots.js.map +1 -0
- package/dist/runtime/staging.d.ts +205 -0
- package/dist/runtime/staging.d.ts.map +1 -0
- package/dist/runtime/staging.js +526 -0
- package/dist/runtime/staging.js.map +1 -0
- package/dist/runtime/task-session.d.ts +95 -0
- package/dist/runtime/task-session.d.ts.map +1 -0
- package/dist/runtime/task-session.js +263 -0
- package/dist/runtime/task-session.js.map +1 -0
- package/dist/runtime/worktree.d.ts +55 -0
- package/dist/runtime/worktree.d.ts.map +1 -0
- package/dist/runtime/worktree.js +175 -0
- package/dist/runtime/worktree.js.map +1 -0
- package/dist/setup-wizard.d.ts +8 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +73 -0
- package/dist/setup-wizard.js.map +1 -0
- package/dist/shared/content.d.ts +43 -0
- package/dist/shared/content.d.ts.map +1 -0
- package/dist/shared/content.js +61 -0
- package/dist/shared/content.js.map +1 -0
- package/dist/shared/types.d.ts +217 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +3 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/test-runner.d.ts +5 -0
- package/dist/test-runner.d.ts.map +1 -0
- package/dist/test-runner.js +42 -0
- package/dist/test-runner.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/ascii.d.ts +23 -0
- package/dist/ui/ascii.d.ts.map +1 -0
- package/dist/ui/ascii.js +45 -0
- package/dist/ui/ascii.js.map +1 -0
- package/dist/ui/colors.d.ts +111 -0
- package/dist/ui/colors.d.ts.map +1 -0
- package/dist/ui/colors.js +166 -0
- package/dist/ui/colors.js.map +1 -0
- package/dist/ui/image-attach.d.ts +27 -0
- package/dist/ui/image-attach.d.ts.map +1 -0
- package/dist/ui/image-attach.js +100 -0
- package/dist/ui/image-attach.js.map +1 -0
- package/dist/ui/index.d.ts +18 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +18 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/markdown-stream.d.ts +91 -0
- package/dist/ui/markdown-stream.d.ts.map +1 -0
- package/dist/ui/markdown-stream.js +524 -0
- package/dist/ui/markdown-stream.js.map +1 -0
- package/dist/ui/plan-renderer.d.ts +36 -0
- package/dist/ui/plan-renderer.d.ts.map +1 -0
- package/dist/ui/plan-renderer.js +79 -0
- package/dist/ui/plan-renderer.js.map +1 -0
- package/dist/ui/prompt.d.ts +11 -0
- package/dist/ui/prompt.d.ts.map +1 -0
- package/dist/ui/prompt.js +1960 -0
- package/dist/ui/prompt.js.map +1 -0
- package/dist/ui/render-primitives.d.ts +117 -0
- package/dist/ui/render-primitives.d.ts.map +1 -0
- package/dist/ui/render-primitives.js +322 -0
- package/dist/ui/render-primitives.js.map +1 -0
- package/dist/ui/render.d.ts +133 -0
- package/dist/ui/render.d.ts.map +1 -0
- package/dist/ui/render.js +547 -0
- package/dist/ui/render.js.map +1 -0
- package/dist/ui/session-header.d.ts +30 -0
- package/dist/ui/session-header.d.ts.map +1 -0
- package/dist/ui/session-header.js +74 -0
- package/dist/ui/session-header.js.map +1 -0
- package/dist/workspace-guard.d.ts +68 -0
- package/dist/workspace-guard.d.ts.map +1 -0
- package/dist/workspace-guard.js +168 -0
- package/dist/workspace-guard.js.map +1 -0
- package/dist/workspace-lock.d.ts +27 -0
- package/dist/workspace-lock.d.ts.map +1 -0
- package/dist/workspace-lock.js +95 -0
- package/dist/workspace-lock.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,1960 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive REPL shell for FixO CLI.
|
|
3
|
+
* Provides command handling, file pinning, model selection,
|
|
4
|
+
* and routes user input to the SingleAgent.
|
|
5
|
+
*/
|
|
6
|
+
import readline from 'readline';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import * as p from '@clack/prompts';
|
|
11
|
+
import { SingleAgent } from '../agent/single-agent.js';
|
|
12
|
+
import { ConversationManager } from '../agent/conversation.js';
|
|
13
|
+
import { GitManager } from '../git/git-manager.js';
|
|
14
|
+
import { loadImageAsBlock } from './image-attach.js';
|
|
15
|
+
import { saveConfig } from '../config.js';
|
|
16
|
+
import { WorkspaceGuard } from '../workspace-guard.js';
|
|
17
|
+
import { listRuns, showRun, undoRun } from '../runtime/task-session.js';
|
|
18
|
+
import { checkPermission } from '../agent/permissions.js';
|
|
19
|
+
import { redactedEnv, redactSecrets } from '../runtime/redaction.js';
|
|
20
|
+
import { appendMemory, doctor, forgetMemory, readMemory } from '../project-memory.js';
|
|
21
|
+
import { buildIndex, explainIndexedTarget, findInIndex } from '../indexer.js';
|
|
22
|
+
import { reviewWorkspace } from '../review.js';
|
|
23
|
+
import { runProjectTests } from '../test-runner.js';
|
|
24
|
+
import { loadPlan, renderPlan, savePlan, classifyComplexityHeuristic } from '../planner.js';
|
|
25
|
+
import { mcpManager, mcpBridgeManager } from '../agent/tool-executor.js';
|
|
26
|
+
import { ProvidersManager, PROVIDER_REGISTRY } from '../agent/providers-manager.js';
|
|
27
|
+
import { C, colors } from './colors.js';
|
|
28
|
+
import { COMMANDS_WITH_DESC, printHelp, formatInputPaths } from './render.js';
|
|
29
|
+
import { addItem, loadTodoList, removeItem, renderTodoList, saveTodoList, setItemStatus, summariseTodoList, } from '../context/todo.js';
|
|
30
|
+
import { renderStatusBar } from './render-primitives.js';
|
|
31
|
+
const c = {
|
|
32
|
+
...colors,
|
|
33
|
+
};
|
|
34
|
+
export async function startREPL(options) {
|
|
35
|
+
const { config, projectConfig, cwd, verbose, resume } = options;
|
|
36
|
+
// ──── Initialize components ────
|
|
37
|
+
const agent = new SingleAgent(verbose);
|
|
38
|
+
const conversation = new ConversationManager();
|
|
39
|
+
const git = new GitManager(cwd);
|
|
40
|
+
const guard = new WorkspaceGuard(cwd);
|
|
41
|
+
const branch = git.isGitRepo() ? git.getCurrentBranch() : '';
|
|
42
|
+
// Initialize local skills and local MCP bridge
|
|
43
|
+
const { skillsManager } = await import('../agent/skills.js');
|
|
44
|
+
skillsManager.initialize(cwd);
|
|
45
|
+
await mcpBridgeManager.initialize(cwd);
|
|
46
|
+
const { randomUUID } = await import('node:crypto');
|
|
47
|
+
let currentSessionId = randomUUID();
|
|
48
|
+
let sessionModifiedFiles = [];
|
|
49
|
+
let currentMode = 'BUILD';
|
|
50
|
+
let currentModel = projectConfig?.model ?? config.defaultModel ?? 'auto';
|
|
51
|
+
conversation.setContextLimit(currentModel);
|
|
52
|
+
let selectedFiles = [];
|
|
53
|
+
// Image (or future non-text) blocks the user has queued with
|
|
54
|
+
// `/image`. Drained into AgentContext.pendingAttachments on the
|
|
55
|
+
// next non-slash input, then cleared.
|
|
56
|
+
let pendingAttachments = [];
|
|
57
|
+
// ──── --resume <id> ────
|
|
58
|
+
if (resume) {
|
|
59
|
+
try {
|
|
60
|
+
const { loadSnapshot, listSnapshots } = await import('../runtime/session-snapshots.js');
|
|
61
|
+
const result = loadSnapshot(cwd, resume);
|
|
62
|
+
if (!result.ok || !result.snapshot) {
|
|
63
|
+
console.log(`\n${c.red}✗ Resume failed: ${result.error ?? 'unknown error'}${c.reset}`);
|
|
64
|
+
const available = listSnapshots(cwd);
|
|
65
|
+
if (available.length > 0) {
|
|
66
|
+
console.log(`\n${c.dim}Available snapshots for this workspace:${c.reset}`);
|
|
67
|
+
for (const s of available.slice(0, 5)) {
|
|
68
|
+
console.log(` ${c.cyan}${s.id}${c.reset} ${c.dim}(${s.items} items, ${s.tokens} tokens)${c.reset}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const snap = result.snapshot;
|
|
74
|
+
conversation.restoreFromSnapshot(snap.conversation.map((m) => ({ role: m.role, content: m.content, name: m.name })), snap.summary ?? '', snap.tokens);
|
|
75
|
+
currentModel = snap.model;
|
|
76
|
+
conversation.setContextLimit(currentModel);
|
|
77
|
+
currentMode = snap.mode;
|
|
78
|
+
selectedFiles = [...snap.selectedFiles];
|
|
79
|
+
console.log(`\n${c.green}✓ Resumed session${c.reset} ${c.dim}${snap.id}${c.reset}`);
|
|
80
|
+
console.log(` ${c.dim}messages=${snap.conversation.length} tokens=${snap.tokens} model=${snap.model} mode=${snap.mode}${c.reset}`);
|
|
81
|
+
if (snap.summary) {
|
|
82
|
+
console.log(` ${c.dim}summary: ${snap.summary}${c.reset}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.log(`\n${c.red}✗ Resume failed: ${err.message}${c.reset}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let isPrompting = false;
|
|
91
|
+
let activeSuggestionsCount = 0;
|
|
92
|
+
let currentMatches = [];
|
|
93
|
+
let highlightedIndex = 0;
|
|
94
|
+
let workspaceFiles = [];
|
|
95
|
+
try {
|
|
96
|
+
const { loadIndex } = await import('../indexer.js');
|
|
97
|
+
const index = await loadIndex(cwd);
|
|
98
|
+
workspaceFiles = index.files.map(f => f.path);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// Ignore
|
|
102
|
+
}
|
|
103
|
+
let lastPromptRow = 0;
|
|
104
|
+
let mouseReportingEnabled = false;
|
|
105
|
+
const stats = {
|
|
106
|
+
totalPromptTokens: 0,
|
|
107
|
+
totalCompletionTokens: 0,
|
|
108
|
+
totalToolCalls: 0,
|
|
109
|
+
totalTasks: 0,
|
|
110
|
+
totalDurationMs: 0,
|
|
111
|
+
};
|
|
112
|
+
// The welcome screen (lava logo + command grid) is printed by
|
|
113
|
+
// `src/index.ts` before the REPL starts; the startREPL entry
|
|
114
|
+
// point jumps straight into the prompt loop.
|
|
115
|
+
if (projectConfig?.systemPrompt) {
|
|
116
|
+
console.log(`${c.dim}📋 Project config loaded (.freellmapi.yml)${c.reset}`);
|
|
117
|
+
}
|
|
118
|
+
const historyFile = path.join(os.homedir(), '.fixo_history');
|
|
119
|
+
let commandHistory = [];
|
|
120
|
+
try {
|
|
121
|
+
if (fs.existsSync(historyFile)) {
|
|
122
|
+
commandHistory = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
|
|
127
|
+
console.warn(`[Debug Warning] Failed to read command history from ${historyFile}: ${error.message || error}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ──── Create readline interface ────
|
|
131
|
+
const rl = readline.createInterface({
|
|
132
|
+
input: process.stdin,
|
|
133
|
+
output: process.stdout,
|
|
134
|
+
terminal: true,
|
|
135
|
+
history: commandHistory,
|
|
136
|
+
historySize: 1000,
|
|
137
|
+
completer: (line) => {
|
|
138
|
+
const list = COMMANDS_WITH_DESC.map((c) => c.cmd);
|
|
139
|
+
if (line.startsWith('/')) {
|
|
140
|
+
const matches = list.filter((cmd) => cmd.startsWith(line));
|
|
141
|
+
return [matches, line];
|
|
142
|
+
}
|
|
143
|
+
return [[], line];
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
// ──── Lava status bar ────
|
|
147
|
+
// The new lava-redesign status bar lives directly above the REPL
|
|
148
|
+
// prompt. It re-renders on every mode change and every model
|
|
149
|
+
// change, plus whenever the user starts a new turn (via
|
|
150
|
+
// `promptForInput` below).
|
|
151
|
+
//
|
|
152
|
+
// We map our internal 4-mode enum onto the 3-mode `CLIState`
|
|
153
|
+
// contract that the renderer expects: EXPLORE/SCOUT collapse to
|
|
154
|
+
// BUILD (the default lava-coloured pill). This keeps the
|
|
155
|
+
// existing /mode command semantics intact while still letting
|
|
156
|
+
// the new bar visualise the live mode.
|
|
157
|
+
const buildLavaStatusState = () => {
|
|
158
|
+
const modeForState = currentMode === 'PLAN' ? 'PLAN' :
|
|
159
|
+
currentMode === 'BUILD' ? 'BUILD' :
|
|
160
|
+
'BUILD';
|
|
161
|
+
let contextPercent = 0;
|
|
162
|
+
try {
|
|
163
|
+
const used = conversation.getTotalTokens();
|
|
164
|
+
const limit = conversation.getContextLimit();
|
|
165
|
+
if (limit > 0) {
|
|
166
|
+
contextPercent = Math.min(100, Math.round((used / limit) * 100));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Conversation not yet hydrated — show 0% rather than NaN.
|
|
171
|
+
}
|
|
172
|
+
let providersCount = 0;
|
|
173
|
+
try {
|
|
174
|
+
providersCount = ProvidersManager.list().length;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Vault not yet available — show 0.
|
|
178
|
+
}
|
|
179
|
+
const currentBranch = git.isGitRepo() ? git.getCurrentBranch() : '';
|
|
180
|
+
return {
|
|
181
|
+
mode: modeForState,
|
|
182
|
+
routing: 'auto',
|
|
183
|
+
model: currentModel,
|
|
184
|
+
branch: currentBranch || 'detached',
|
|
185
|
+
contextPercent,
|
|
186
|
+
providersCount,
|
|
187
|
+
transport: 'freellmapi',
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
const drawLavaStatusBar = () => {
|
|
191
|
+
// renderStatusBar writes a single `\r` line (no newline) so the
|
|
192
|
+
// REPL prompt can sit on the same row as a redo. For the
|
|
193
|
+
// normal "above the prompt" layout we want a full line of its
|
|
194
|
+
// own, so we manually append a newline after the renderer
|
|
195
|
+
// returns.
|
|
196
|
+
renderStatusBar(buildLavaStatusState());
|
|
197
|
+
process.stdout.write('\n');
|
|
198
|
+
};
|
|
199
|
+
// Surface the result of a live model fetch as a one-line status.
|
|
200
|
+
// Invoked from /providers add and /providers test so the user
|
|
201
|
+
// immediately sees whether the live API was reachable or whether
|
|
202
|
+
// the picker will fall back to the cached / registry list.
|
|
203
|
+
const refreshModelsForProvider = async (name) => {
|
|
204
|
+
try {
|
|
205
|
+
const result = await ProvidersManager.fetchRemoteModels(name);
|
|
206
|
+
if (result.source === 'live') {
|
|
207
|
+
console.log(`${c.green}✓ Fetched ${result.models.length} models from live API.${c.reset}`);
|
|
208
|
+
}
|
|
209
|
+
else if (result.source === 'cache') {
|
|
210
|
+
const ageHours = Math.max(0, Math.round((Date.now() - Date.parse(result.fetchedAt)) / (60 * 60 * 1000)));
|
|
211
|
+
console.log(`${c.yellow}⚠ Live fetch unavailable — using cached list (~${ageHours}h old).${c.reset}`);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
console.log(`${c.yellow}⚠ Live fetch failed — using built-in registry list (marked [unverified] in /model).${c.reset}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
console.log(`${c.dim} (model list refresh skipped: ${err?.message ?? err})${c.reset}`);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
// ──── Mouse Reporting Helpers ────
|
|
222
|
+
function enableMouseReporting() {
|
|
223
|
+
if (process.stdout.isTTY && !mouseReportingEnabled) {
|
|
224
|
+
process.stdout.write('\x1b[?1003h\x1b[?1006h');
|
|
225
|
+
mouseReportingEnabled = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function disableMouseReporting() {
|
|
229
|
+
if (process.stdout.isTTY && mouseReportingEnabled) {
|
|
230
|
+
process.stdout.write('\x1b[?1003l\x1b[?1006l');
|
|
231
|
+
mouseReportingEnabled = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function disableMouseReportingSync() {
|
|
235
|
+
try {
|
|
236
|
+
if (process.stdout.isTTY && mouseReportingEnabled) {
|
|
237
|
+
fs.writeSync(1, '\x1b[?1003l\x1b[?1006l');
|
|
238
|
+
mouseReportingEnabled = false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
|
|
243
|
+
console.warn(`[Debug Warning] Failed to disable mouse reporting: ${e.message || e}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Register synchronous exit cleanups
|
|
248
|
+
const exitCleanup = () => {
|
|
249
|
+
try {
|
|
250
|
+
const hist = rl.history;
|
|
251
|
+
if (Array.isArray(hist)) {
|
|
252
|
+
fs.writeFileSync(historyFile, hist.join('\n'), 'utf-8');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
|
|
257
|
+
console.warn(`[Debug Warning] Failed to write history file on exit: ${error.message || error}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
disableMouseReportingSync();
|
|
261
|
+
mcpManager.shutdown();
|
|
262
|
+
mcpBridgeManager.shutdown();
|
|
263
|
+
// Restore the original `process.stdin.emit` so a Ctrl-C or
|
|
264
|
+
// uncaught-exit doesn't leave the monkey-patch installed.
|
|
265
|
+
// Previously this was only done on `/exit`, so SIGINT and
|
|
266
|
+
// SIGTERM corrupted subsequent stdin listeners.
|
|
267
|
+
try {
|
|
268
|
+
process.stdin.emit = originalEmit;
|
|
269
|
+
process.stdin.off('keypress', keypressHandler);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// ignore — process may already be tearing down
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
process.on('exit', exitCleanup);
|
|
276
|
+
// ──── Graceful exit handlers ────
|
|
277
|
+
const sigintHandler = () => {
|
|
278
|
+
exitCleanup();
|
|
279
|
+
console.log('\n\n👋 FixO CLI session ended safely. Core engine offline.');
|
|
280
|
+
process.exit(0);
|
|
281
|
+
};
|
|
282
|
+
process.on('SIGINT', sigintHandler);
|
|
283
|
+
const sigtermHandler = () => {
|
|
284
|
+
exitCleanup();
|
|
285
|
+
process.exit(0);
|
|
286
|
+
};
|
|
287
|
+
process.on('SIGTERM', sigtermHandler);
|
|
288
|
+
const uncaughtExceptionHandler = (err) => {
|
|
289
|
+
exitCleanup();
|
|
290
|
+
console.error('\n🔥 Uncaught Exception:', err);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
};
|
|
293
|
+
process.on('uncaughtException', uncaughtExceptionHandler);
|
|
294
|
+
// ──── Suggestion Box Helpers ────
|
|
295
|
+
function clearSuggestions() {
|
|
296
|
+
if (activeSuggestionsCount > 0) {
|
|
297
|
+
disableMouseReporting();
|
|
298
|
+
const currentCursor = rl.cursor;
|
|
299
|
+
readline.moveCursor(process.stdout, 0, 1);
|
|
300
|
+
readline.cursorTo(process.stdout, 0);
|
|
301
|
+
process.stdout.write('\x1b[J');
|
|
302
|
+
readline.moveCursor(process.stdout, 0, -1);
|
|
303
|
+
readline.cursorTo(process.stdout, 2 + currentCursor);
|
|
304
|
+
activeSuggestionsCount = 0;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function drawSuggestions(matches) {
|
|
308
|
+
clearSuggestions();
|
|
309
|
+
if (matches.length === 0)
|
|
310
|
+
return;
|
|
311
|
+
enableMouseReporting();
|
|
312
|
+
const currentCursor = rl.cursor;
|
|
313
|
+
let output = '\n';
|
|
314
|
+
const width = 60;
|
|
315
|
+
const borderTop = `${c.snow}┌────────────────────────────────────────────────────────┐${c.reset}\n`;
|
|
316
|
+
const borderBottom = `${c.snow}└────────────────────────────────────────────────────────┘${c.reset}`;
|
|
317
|
+
output += borderTop;
|
|
318
|
+
let startIndex = 0;
|
|
319
|
+
if (highlightedIndex >= 8) {
|
|
320
|
+
startIndex = highlightedIndex - 7;
|
|
321
|
+
}
|
|
322
|
+
const visibleMatches = matches.slice(startIndex, startIndex + 8);
|
|
323
|
+
visibleMatches.forEach((item, index) => {
|
|
324
|
+
const actualIndex = startIndex + index;
|
|
325
|
+
const isHighlighted = actualIndex === highlightedIndex;
|
|
326
|
+
const prefix = isHighlighted ? '❯ ' : ' ';
|
|
327
|
+
const displayStr = item.display;
|
|
328
|
+
const descStr = item.desc || '';
|
|
329
|
+
const displayLimit = 25;
|
|
330
|
+
const descLimit = 28;
|
|
331
|
+
let dispText = displayStr;
|
|
332
|
+
if (dispText.length > displayLimit) {
|
|
333
|
+
dispText = dispText.slice(0, displayLimit - 3) + '...';
|
|
334
|
+
}
|
|
335
|
+
dispText = dispText.padEnd(displayLimit);
|
|
336
|
+
let descText = descStr;
|
|
337
|
+
if (descText.length > descLimit) {
|
|
338
|
+
descText = descText.slice(0, descLimit - 3) + '...';
|
|
339
|
+
}
|
|
340
|
+
descText = descText.padEnd(descLimit);
|
|
341
|
+
if (isHighlighted) {
|
|
342
|
+
output += `${c.snow}│${c.reset} \x1b[48;5;236m\x1b[38;5;208m${prefix}${dispText} ${c.dim}${descText}\x1b[0m ${c.snow}│${c.reset}\n`;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
output += `${c.snow}│${c.reset} ${prefix}${dispText} ${c.dim}${descText}${c.reset} ${c.snow}│${c.reset}\n`;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
if (matches.length > 8) {
|
|
349
|
+
const remaining = matches.length - 8;
|
|
350
|
+
const moreStr = `... and ${remaining} more matches`.padEnd(54);
|
|
351
|
+
output += `${c.snow}│${c.reset} ${c.dim}${moreStr}${c.reset} ${c.snow}│${c.reset}\n`;
|
|
352
|
+
}
|
|
353
|
+
output += borderBottom;
|
|
354
|
+
activeSuggestionsCount = visibleMatches.length + (matches.length > 8 ? 1 : 0) + 2;
|
|
355
|
+
process.stdout.write(output);
|
|
356
|
+
readline.moveCursor(process.stdout, 0, -activeSuggestionsCount);
|
|
357
|
+
readline.cursorTo(process.stdout, 2 + currentCursor);
|
|
358
|
+
// Request cursor position asynchronously
|
|
359
|
+
process.stdout.write('\x1b[6n');
|
|
360
|
+
}
|
|
361
|
+
function getActiveToken(lineStr, cursorOffset) {
|
|
362
|
+
const beforeCursor = lineStr.slice(0, cursorOffset);
|
|
363
|
+
const lastSlash = beforeCursor.lastIndexOf('/');
|
|
364
|
+
const lastAt = beforeCursor.lastIndexOf('@');
|
|
365
|
+
const lastTriggerIdx = Math.max(lastSlash, lastAt);
|
|
366
|
+
if (lastTriggerIdx === -1) {
|
|
367
|
+
return { trigger: null, query: '', index: -1 };
|
|
368
|
+
}
|
|
369
|
+
if (lastTriggerIdx > 0 && !/\s/.test(beforeCursor[lastTriggerIdx - 1])) {
|
|
370
|
+
return { trigger: null, query: '', index: -1 };
|
|
371
|
+
}
|
|
372
|
+
const trigger = lastTriggerIdx === lastSlash ? '/' : '@';
|
|
373
|
+
const query = beforeCursor.slice(lastTriggerIdx + 1);
|
|
374
|
+
if (/\s/.test(query)) {
|
|
375
|
+
return { trigger: null, query: '', index: -1 };
|
|
376
|
+
}
|
|
377
|
+
return { trigger, query, index: lastTriggerIdx };
|
|
378
|
+
}
|
|
379
|
+
function getSuggestions(lineStr, cursorOffset) {
|
|
380
|
+
const active = getActiveToken(lineStr, cursorOffset);
|
|
381
|
+
if (!active.trigger) {
|
|
382
|
+
return { options: [], trigger: null, query: '', triggerIndex: -1 };
|
|
383
|
+
}
|
|
384
|
+
const q = active.query.toLowerCase();
|
|
385
|
+
if (active.trigger === '/') {
|
|
386
|
+
const matches = COMMANDS_WITH_DESC.filter(c => c.cmd.toLowerCase().startsWith(active.query.toLowerCase() ? '/' + active.query.toLowerCase() : '/'));
|
|
387
|
+
const options = matches.map(m => ({
|
|
388
|
+
display: m.cmd,
|
|
389
|
+
value: m.cmd + ' ',
|
|
390
|
+
desc: m.desc,
|
|
391
|
+
}));
|
|
392
|
+
return { options, trigger: '/', query: active.query, triggerIndex: active.index };
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const options = [];
|
|
396
|
+
const subagents = [
|
|
397
|
+
{ name: 'code', desc: 'Code Agent: read and modify workspace files' },
|
|
398
|
+
{ name: 'test', desc: 'Test Agent: write, run, or fix tests' },
|
|
399
|
+
{ name: 'doc', desc: 'Documentation Agent: edit markdown and docstrings' },
|
|
400
|
+
{ name: 'reviewer', desc: 'Reviewer Agent: audit diffs and code modifications' },
|
|
401
|
+
];
|
|
402
|
+
for (const sa of subagents) {
|
|
403
|
+
const key = '@' + sa.name;
|
|
404
|
+
if (!active.query || sa.name.toLowerCase().startsWith(q)) {
|
|
405
|
+
options.push({
|
|
406
|
+
display: key,
|
|
407
|
+
value: key + ' ',
|
|
408
|
+
desc: sa.desc,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const list = skillsManager.getSkills();
|
|
414
|
+
for (const s of list) {
|
|
415
|
+
const key = '@' + s.name;
|
|
416
|
+
if (!active.query || s.name.toLowerCase().startsWith(q)) {
|
|
417
|
+
options.push({
|
|
418
|
+
display: key,
|
|
419
|
+
value: key + ' ',
|
|
420
|
+
desc: s.description || 'Skill profile',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
|
|
427
|
+
console.warn(`[Debug Warning] Failed to load skills list: ${error.message || error}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const matchingFiles = workspaceFiles.filter(f => f.toLowerCase().includes(q) || path.basename(f).toLowerCase().startsWith(q));
|
|
431
|
+
matchingFiles.sort((a, b) => {
|
|
432
|
+
const baseA = path.basename(a).toLowerCase();
|
|
433
|
+
const baseB = path.basename(b).toLowerCase();
|
|
434
|
+
const aStarts = baseA.startsWith(q);
|
|
435
|
+
const bStarts = baseB.startsWith(q);
|
|
436
|
+
if (aStarts && !bStarts)
|
|
437
|
+
return -1;
|
|
438
|
+
if (!aStarts && bStarts)
|
|
439
|
+
return 1;
|
|
440
|
+
return a.localeCompare(b);
|
|
441
|
+
});
|
|
442
|
+
for (const file of matchingFiles.slice(0, 12)) {
|
|
443
|
+
const key = '@' + file;
|
|
444
|
+
options.push({
|
|
445
|
+
display: '@' + path.basename(file),
|
|
446
|
+
value: key + ' ',
|
|
447
|
+
desc: file,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return { options, trigger: '@', query: active.query, triggerIndex: active.index };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// ──── Keypress registration ────
|
|
454
|
+
readline.emitKeypressEvents(process.stdin);
|
|
455
|
+
if (process.stdin.isTTY) {
|
|
456
|
+
process.stdin.setRawMode(true);
|
|
457
|
+
}
|
|
458
|
+
const keypressHandler = (_char, key) => {
|
|
459
|
+
if (!isPrompting)
|
|
460
|
+
return;
|
|
461
|
+
if (key && (key.name === 'up' || key.name === 'down' || key.name === 'escape' || key.name === 'tab' || key.name === 'enter' || key.name === 'return')) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
process.nextTick(() => {
|
|
465
|
+
if (!isPrompting)
|
|
466
|
+
return;
|
|
467
|
+
const line = rl.line;
|
|
468
|
+
const cursor = rl.cursor;
|
|
469
|
+
const suggs = getSuggestions(line, cursor);
|
|
470
|
+
if (suggs.trigger) {
|
|
471
|
+
const oldMatchesCount = currentMatches.length;
|
|
472
|
+
currentMatches = suggs.options;
|
|
473
|
+
if (currentMatches.length !== oldMatchesCount) {
|
|
474
|
+
highlightedIndex = 0;
|
|
475
|
+
}
|
|
476
|
+
drawSuggestions(currentMatches);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
clearSuggestions();
|
|
480
|
+
currentMatches = [];
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
process.stdin.on('keypress', keypressHandler);
|
|
485
|
+
let mouseBuffer = '';
|
|
486
|
+
// Monkey-patch process.stdin.emit to intercept keypress and mouse events
|
|
487
|
+
const originalEmit = process.stdin.emit;
|
|
488
|
+
process.stdin.emit = function (event, ...args) {
|
|
489
|
+
if (event === 'data') {
|
|
490
|
+
const rawData = args[0];
|
|
491
|
+
if (rawData) {
|
|
492
|
+
let str = mouseBuffer + rawData.toString();
|
|
493
|
+
mouseBuffer = '';
|
|
494
|
+
// Intercept cursor position response
|
|
495
|
+
if (str.startsWith('\x1b[') && str.endsWith('R')) {
|
|
496
|
+
const match = str.match(/\x1b\[(\d+);(\d+)R/);
|
|
497
|
+
if (match) {
|
|
498
|
+
lastPromptRow = parseInt(match[1], 10);
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Remove fully-formed SGR mouse events
|
|
503
|
+
str = str.replace(/\x1b\[<[0-9;]+[Mm]/g, '');
|
|
504
|
+
// Buffer any trailing partial SGR mouse event
|
|
505
|
+
const partialIdx = str.lastIndexOf('\x1b[<');
|
|
506
|
+
if (partialIdx !== -1) {
|
|
507
|
+
const remaining = str.slice(partialIdx);
|
|
508
|
+
if (!/[Mm]/.test(remaining)) {
|
|
509
|
+
mouseBuffer = remaining;
|
|
510
|
+
str = str.slice(0, partialIdx);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Process mouse events for suggestions list if present in raw data
|
|
514
|
+
const mouseMatches = rawData.toString().match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g);
|
|
515
|
+
if (mouseMatches) {
|
|
516
|
+
for (const rawMatch of mouseMatches) {
|
|
517
|
+
const m = rawMatch.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
518
|
+
if (m) {
|
|
519
|
+
const [_, buttonStr, colStr, rowStr, action] = m;
|
|
520
|
+
const button = parseInt(buttonStr, 10);
|
|
521
|
+
const clickRow = parseInt(rowStr, 10);
|
|
522
|
+
const isPressed = action === 'M';
|
|
523
|
+
if (activeSuggestionsCount > 0 && lastPromptRow > 0) {
|
|
524
|
+
// Mouse Scroll UP
|
|
525
|
+
if (button === 64) {
|
|
526
|
+
highlightedIndex = (highlightedIndex - 1 + currentMatches.length) % currentMatches.length;
|
|
527
|
+
drawSuggestions(currentMatches);
|
|
528
|
+
}
|
|
529
|
+
// Mouse Scroll DOWN
|
|
530
|
+
else if (button === 65) {
|
|
531
|
+
highlightedIndex = (highlightedIndex + 1) % currentMatches.length;
|
|
532
|
+
drawSuggestions(currentMatches);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const boxStartRow = lastPromptRow + 1;
|
|
536
|
+
let startIndex = 0;
|
|
537
|
+
if (highlightedIndex >= 8) {
|
|
538
|
+
startIndex = highlightedIndex - 7;
|
|
539
|
+
}
|
|
540
|
+
const clickedItemIndex = clickRow - boxStartRow - 1;
|
|
541
|
+
const actualHoveredIndex = startIndex + clickedItemIndex;
|
|
542
|
+
if (actualHoveredIndex >= 0 && actualHoveredIndex < currentMatches.length && clickedItemIndex < Math.min(currentMatches.length, 8)) {
|
|
543
|
+
// Mouse hover/motion
|
|
544
|
+
if (button === 35 || button === 32) {
|
|
545
|
+
if (highlightedIndex !== actualHoveredIndex) {
|
|
546
|
+
highlightedIndex = actualHoveredIndex;
|
|
547
|
+
drawSuggestions(currentMatches);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Left click press
|
|
551
|
+
else if (button === 0 && isPressed) {
|
|
552
|
+
highlightedIndex = actualHoveredIndex;
|
|
553
|
+
const selected = currentMatches[highlightedIndex];
|
|
554
|
+
if (selected) {
|
|
555
|
+
const line = rl.line;
|
|
556
|
+
const cursor = rl.cursor;
|
|
557
|
+
const active = getActiveToken(line, cursor);
|
|
558
|
+
if (active.index !== -1) {
|
|
559
|
+
const beforeTrigger = line.slice(0, active.index);
|
|
560
|
+
const afterCursor = line.slice(cursor);
|
|
561
|
+
const newLine = beforeTrigger + selected.value + afterCursor;
|
|
562
|
+
rl.write(null, { ctrl: true, name: 'u' });
|
|
563
|
+
rl.write(newLine);
|
|
564
|
+
const moveCount = newLine.length - (beforeTrigger.length + selected.value.length);
|
|
565
|
+
for (let i = 0; i < moveCount; i++) {
|
|
566
|
+
rl.write(null, { name: 'left' });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
clearSuggestions();
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// If the remaining string is empty, forward an empty buffer rather than swallowing
|
|
580
|
+
args[0] = str.length > 0 ? Buffer.from(str) : Buffer.alloc(0);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (event === 'keypress') {
|
|
584
|
+
const [char, key] = args;
|
|
585
|
+
// Tab on empty line → cycle mode (BEFORE suggestion handling, so it always works)
|
|
586
|
+
if (isPrompting && key && key.name === 'tab' && rl.line.trim() === '') {
|
|
587
|
+
const modes = ['BUILD', 'EXPLORE', 'SCOUT', 'PLAN'];
|
|
588
|
+
const nextIndex = (modes.indexOf(currentMode) + 1) % modes.length;
|
|
589
|
+
currentMode = modes[nextIndex];
|
|
590
|
+
// Clear readline state
|
|
591
|
+
rl.line = '';
|
|
592
|
+
rl.cursor = 0;
|
|
593
|
+
// Clear current prompt line:
|
|
594
|
+
process.stdout.write('\r\x1b[K');
|
|
595
|
+
// Re-draw the lava status bar with the new mode. The
|
|
596
|
+
// legacy dirLabel/branchLabel/modelLabel/modeLabel row
|
|
597
|
+
// is gone — the new bar carries all of that information.
|
|
598
|
+
drawLavaStatusBar();
|
|
599
|
+
process.stdout.write(`${C.LAVA}›${C.RESET} `);
|
|
600
|
+
return true; // swallow keypress
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return originalEmit.apply(this, [event, ...args]);
|
|
604
|
+
};
|
|
605
|
+
// ──── REPL loop ────
|
|
606
|
+
const promptForInput = () => {
|
|
607
|
+
// Restore raw mode and resume streams to recover from any clack/spinner interactions
|
|
608
|
+
if (process.stdin.isTTY) {
|
|
609
|
+
process.stdin.setRawMode(true);
|
|
610
|
+
}
|
|
611
|
+
process.stdin.resume();
|
|
612
|
+
rl.resume();
|
|
613
|
+
// The new lava status bar is the ONLY status surface — it
|
|
614
|
+
// replaces the legacy dirLabel/branchLabel/modelLabel/modeLabel
|
|
615
|
+
// row entirely. Mode + model + branch + context usage are all
|
|
616
|
+
// visible in the bar; the prompt itself is the lava `›` glyph.
|
|
617
|
+
drawLavaStatusBar();
|
|
618
|
+
isPrompting = true;
|
|
619
|
+
rl.question(`${C.LAVA}›${C.RESET} `, async (input) => {
|
|
620
|
+
isPrompting = false;
|
|
621
|
+
disableMouseReporting();
|
|
622
|
+
clearSuggestions();
|
|
623
|
+
const trimmed = input.trim();
|
|
624
|
+
if (!trimmed) {
|
|
625
|
+
promptForInput();
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
await handleInput(trimmed);
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
633
|
+
console.log(`\n${c.red}✗ Error: ${msg}${c.reset}`);
|
|
634
|
+
// Actionable error suggestions
|
|
635
|
+
if (msg.includes('ECONNREFUSED')) {
|
|
636
|
+
console.log(`${c.dim} → Proxy server is down. Restart with: npm run dev${c.reset}`);
|
|
637
|
+
}
|
|
638
|
+
else if (msg.includes('413')) {
|
|
639
|
+
console.log(`${c.dim} → Reduce context: /unselect to clear pinned files${c.reset}`);
|
|
640
|
+
}
|
|
641
|
+
else if (msg.includes('429')) {
|
|
642
|
+
console.log(`${c.dim} → Rate limited. Wait a moment or add more API keys.${c.reset}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
promptForInput();
|
|
646
|
+
});
|
|
647
|
+
};
|
|
648
|
+
// ──── Input handler ────
|
|
649
|
+
async function handleInput(input) {
|
|
650
|
+
// ─── Slash commands ───
|
|
651
|
+
if (input.startsWith('/')) {
|
|
652
|
+
const parts = input.split(/\s+/).filter(Boolean);
|
|
653
|
+
const cmd = parts[0];
|
|
654
|
+
const args = parts.slice(1);
|
|
655
|
+
switch (cmd) {
|
|
656
|
+
case '/exit':
|
|
657
|
+
case '/quit':
|
|
658
|
+
disableMouseReporting();
|
|
659
|
+
console.log(`\n${c.dim}👋 Goodbye!${c.reset}`);
|
|
660
|
+
process.stdin.off('keypress', keypressHandler);
|
|
661
|
+
process.stdin.emit = originalEmit;
|
|
662
|
+
process.off('exit', exitCleanup);
|
|
663
|
+
process.off('SIGINT', sigintHandler);
|
|
664
|
+
process.off('SIGTERM', sigtermHandler);
|
|
665
|
+
process.off('uncaughtException', uncaughtExceptionHandler);
|
|
666
|
+
rl.close();
|
|
667
|
+
process.exit(0);
|
|
668
|
+
case '/help':
|
|
669
|
+
printHelp();
|
|
670
|
+
return;
|
|
671
|
+
case '/model': {
|
|
672
|
+
if (args[0] === 'list') {
|
|
673
|
+
// Print full model table grouped by provider
|
|
674
|
+
console.log(`\n${c.bold}${c.cyan}Available Models by Provider${c.reset}`);
|
|
675
|
+
console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
|
|
676
|
+
for (const def of PROVIDER_REGISTRY) {
|
|
677
|
+
const hasKey = ProvidersManager.has(def.name);
|
|
678
|
+
const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.dim}[no key]${c.reset}`;
|
|
679
|
+
console.log(`\n ${c.snow}${c.bold}${def.displayName}${c.reset} ${keyStatus}`);
|
|
680
|
+
for (const model of def.models) {
|
|
681
|
+
console.log(` ${c.cyan}•${c.reset} ${model}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
console.log(`\n${c.dim} Use /providers add <name> to connect a provider with your API key.${c.reset}`);
|
|
685
|
+
console.log(`${c.dim} Or set model directly: /model <model-id>${c.reset}\n`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (args.length === 0) {
|
|
689
|
+
// Redesigned interactive model picker grouped by provider
|
|
690
|
+
rl.pause();
|
|
691
|
+
const pickedProvider = await p.select({
|
|
692
|
+
message: `Current model: ${c.cyan}${currentModel}${c.reset} — Select AI Provider:`,
|
|
693
|
+
options: [
|
|
694
|
+
{ value: 'all', label: 'Show all models (flat list)', hint: 'classic view' },
|
|
695
|
+
...PROVIDER_REGISTRY.map(def => ({
|
|
696
|
+
value: def.name,
|
|
697
|
+
label: def.displayName,
|
|
698
|
+
hint: ProvidersManager.has(def.name) ? ' [key ✓]' : ' [no key]'
|
|
699
|
+
})),
|
|
700
|
+
{ value: '__manual__', label: 'Enter model ID manually…', hint: '' },
|
|
701
|
+
],
|
|
702
|
+
initialValue: PROVIDER_REGISTRY.find(def => def.models.includes(currentModel))?.name || 'all',
|
|
703
|
+
});
|
|
704
|
+
rl.resume();
|
|
705
|
+
if (p.isCancel(pickedProvider)) {
|
|
706
|
+
console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (pickedProvider === '__manual__') {
|
|
710
|
+
rl.pause();
|
|
711
|
+
const manual = await p.text({
|
|
712
|
+
message: 'Enter model ID:',
|
|
713
|
+
placeholder: 'e.g. gpt-4o, claude-opus-4-5, gemini-2.5-pro',
|
|
714
|
+
validate: v => !v.trim() ? 'Model ID is required' : undefined,
|
|
715
|
+
});
|
|
716
|
+
rl.resume();
|
|
717
|
+
if (!p.isCancel(manual) && manual) {
|
|
718
|
+
currentModel = manual.trim();
|
|
719
|
+
conversation.setContextLimit(currentModel);
|
|
720
|
+
console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (pickedProvider === 'all') {
|
|
725
|
+
rl.pause();
|
|
726
|
+
const allOptions = PROVIDER_REGISTRY.flatMap(def => def.models.map(m => ({
|
|
727
|
+
value: m,
|
|
728
|
+
label: `${m}`,
|
|
729
|
+
hint: def.displayName + (ProvidersManager.has(def.name) ? ' [key ✓]' : ''),
|
|
730
|
+
})));
|
|
731
|
+
const picked = await p.select({
|
|
732
|
+
message: 'Select a model from the flat list:',
|
|
733
|
+
options: [
|
|
734
|
+
{ value: currentModel, label: `Keep current: ${currentModel}`, hint: 'no change' },
|
|
735
|
+
...allOptions,
|
|
736
|
+
],
|
|
737
|
+
initialValue: currentModel,
|
|
738
|
+
});
|
|
739
|
+
rl.resume();
|
|
740
|
+
if (p.isCancel(picked)) {
|
|
741
|
+
console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
currentModel = picked;
|
|
745
|
+
conversation.setContextLimit(currentModel);
|
|
746
|
+
console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const def = PROVIDER_REGISTRY.find(p => p.name === pickedProvider);
|
|
750
|
+
const hasKey = ProvidersManager.has(def.name);
|
|
751
|
+
const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.red}[no key]${c.reset}`;
|
|
752
|
+
// Prefer the cached live model list; fall back to the
|
|
753
|
+
// registry list (tagged `[unverified]`) when no fresh
|
|
754
|
+
// cache exists. Drops the synthetic "(free)" suffix
|
|
755
|
+
// since we no longer know that without provider
|
|
756
|
+
// metadata.
|
|
757
|
+
const cached = ProvidersManager.getCachedModels(def.name);
|
|
758
|
+
const modelList = cached?.models?.length ? cached.models : def.models;
|
|
759
|
+
const sourceSuffix = cached?.source === 'live'
|
|
760
|
+
? ''
|
|
761
|
+
: ` ${c.dim}[unverified]${c.reset}`;
|
|
762
|
+
rl.pause();
|
|
763
|
+
const picked = await p.select({
|
|
764
|
+
message: `Select a model from ${c.bold}${def.displayName}${c.reset} ${keyStatus}${sourceSuffix}:`,
|
|
765
|
+
options: modelList.map(m => {
|
|
766
|
+
return {
|
|
767
|
+
value: m,
|
|
768
|
+
label: m,
|
|
769
|
+
hint: m === currentModel ? 'currently selected' : ''
|
|
770
|
+
};
|
|
771
|
+
}),
|
|
772
|
+
initialValue: modelList.includes(currentModel) ? currentModel : undefined,
|
|
773
|
+
});
|
|
774
|
+
rl.resume();
|
|
775
|
+
if (p.isCancel(picked)) {
|
|
776
|
+
console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
currentModel = picked;
|
|
780
|
+
conversation.setContextLimit(currentModel);
|
|
781
|
+
console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
currentModel = args.join(' ');
|
|
785
|
+
conversation.setContextLimit(currentModel);
|
|
786
|
+
console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
case '/select': {
|
|
790
|
+
if (args.length === 0) {
|
|
791
|
+
if (selectedFiles.length === 0) {
|
|
792
|
+
console.log(`\n${c.dim}No files selected. Usage: /select <file-path>${c.reset}`);
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
console.log(`\n${c.dim}Selected files:${c.reset}`);
|
|
796
|
+
for (const f of selectedFiles) {
|
|
797
|
+
console.log(` ${c.cyan}${path.basename(f)}${c.reset} ${c.dim}(${f})${c.reset}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
let rawPath = args.join(' ');
|
|
803
|
+
if ((rawPath.startsWith("'") && rawPath.endsWith("'")) ||
|
|
804
|
+
(rawPath.startsWith('"') && rawPath.endsWith('"'))) {
|
|
805
|
+
rawPath = rawPath.slice(1, -1);
|
|
806
|
+
}
|
|
807
|
+
let filePath;
|
|
808
|
+
try {
|
|
809
|
+
filePath = guard.ensureFile(rawPath);
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
console.log(`\n${c.red}✗ ${error instanceof Error ? error.message : String(error)}${c.reset}`);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!fs.existsSync(filePath)) {
|
|
816
|
+
console.log(`\n${c.red}✗ File not found: ${rawPath}${c.reset}`);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (!selectedFiles.includes(filePath)) {
|
|
820
|
+
selectedFiles.push(filePath);
|
|
821
|
+
}
|
|
822
|
+
console.log(`\n${c.green}✓ Pinned: ${c.bold}${path.basename(filePath)}${c.reset}`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
case '/unselect':
|
|
826
|
+
selectedFiles = [];
|
|
827
|
+
console.log(`\n${c.green}✓ All pinned files cleared${c.reset}`);
|
|
828
|
+
return;
|
|
829
|
+
case '/diff':
|
|
830
|
+
console.log(`\n${git.getDiff()}`);
|
|
831
|
+
return;
|
|
832
|
+
case '/undo': {
|
|
833
|
+
if (args[0]) {
|
|
834
|
+
console.log(`\n${undoRun(cwd, args[0])}`);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
rl.pause();
|
|
838
|
+
const confirmed = await p.confirm({
|
|
839
|
+
message: 'Are you sure you want to completely discard the last automated agent commit and restore all files?',
|
|
840
|
+
initialValue: false,
|
|
841
|
+
});
|
|
842
|
+
rl.resume();
|
|
843
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
844
|
+
console.log(`\n${c.yellow} ⚠ Undo cancelled.${c.reset}`);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
git.undoLastCommit();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
case '/clear':
|
|
851
|
+
conversation.clear();
|
|
852
|
+
pendingAttachments = [];
|
|
853
|
+
console.log(`\n${c.green}✓ Conversation cleared${c.reset}`);
|
|
854
|
+
return;
|
|
855
|
+
case '/image': {
|
|
856
|
+
// `/image <path>` — queue a local image for the next turn.
|
|
857
|
+
// `/image clear` — drop the queue.
|
|
858
|
+
// `/image list` — show what's queued.
|
|
859
|
+
const sub = args[0];
|
|
860
|
+
if (sub === 'clear') {
|
|
861
|
+
const n = pendingAttachments.length;
|
|
862
|
+
pendingAttachments = [];
|
|
863
|
+
console.log(`\n${c.green}✓ Cleared ${n} pending image(s)${c.reset}`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (sub === 'list') {
|
|
867
|
+
if (pendingAttachments.length === 0) {
|
|
868
|
+
console.log(`\n${c.dim}No pending images.${c.reset}`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
console.log(`\n${c.bold}Pending images (sent on next prompt):${c.reset}`);
|
|
872
|
+
for (const [i, block] of pendingAttachments.entries()) {
|
|
873
|
+
if (block.type === 'image' && block.source.kind === 'base64') {
|
|
874
|
+
const approxBytes = Math.floor((block.source.data.length * 3) / 4);
|
|
875
|
+
console.log(` ${i + 1}. ${block.source.mediaType} (~${approxBytes} bytes)`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!sub) {
|
|
881
|
+
console.log(`\n${c.yellow}Usage: /image <path> | /image list | /image clear${c.reset}`);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const result = loadImageAsBlock(sub, cwd);
|
|
885
|
+
if (!result.ok) {
|
|
886
|
+
console.log(`\n${c.red}✗ /image: ${result.error}${c.reset}`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
pendingAttachments.push(result.block);
|
|
890
|
+
console.log(`\n${c.green}✓ Attached${c.reset} ${c.dim}${result.mediaType}, ${result.bytes} bytes — will be sent with your next prompt${c.reset}`);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
case '/mcp': {
|
|
894
|
+
const sub = args[0]?.toLowerCase();
|
|
895
|
+
if (!sub || sub === 'list') {
|
|
896
|
+
const { listAllMcpSources, mergedMcpServers } = await import('../agent/mcp-registry.js');
|
|
897
|
+
const view = listAllMcpSources(cwd);
|
|
898
|
+
console.log(`\n${c.bold}${c.cyan}MCP Servers${c.reset} ${c.dim}(project-wins precedence: local > project > global)${c.reset}`);
|
|
899
|
+
console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
|
|
900
|
+
const renderSource = (label, s) => {
|
|
901
|
+
const names = Object.keys(s.servers);
|
|
902
|
+
if (names.length === 0) {
|
|
903
|
+
console.log(` ${c.dim}${label}: (empty)${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
console.log(` ${c.bold}${label}${c.reset}${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
|
|
907
|
+
for (const n of names) {
|
|
908
|
+
console.log(` ${c.cyan}•${c.reset} ${n}`);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
renderSource('global', view.global);
|
|
912
|
+
renderSource('project', view.project);
|
|
913
|
+
renderSource('local', view.local);
|
|
914
|
+
const merged = mergedMcpServers(cwd);
|
|
915
|
+
const mergedCount = Object.keys(merged).length;
|
|
916
|
+
console.log(`\n${c.dim}merged total: ${mergedCount} server(s)${c.reset}`);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (sub === 'add') {
|
|
920
|
+
const name = args[1];
|
|
921
|
+
if (!name || args.length < 3) {
|
|
922
|
+
console.log(`\n${c.yellow}Usage: /mcp add <name> <command> [args...]${c.reset}`);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const cmd = args[2];
|
|
926
|
+
const cmdArgs = args.slice(3);
|
|
927
|
+
const { addLocalMcpServer } = await import('../agent/mcp-registry.js');
|
|
928
|
+
addLocalMcpServer(cwd, name, { command: cmd, args: cmdArgs, type: 'stdio' });
|
|
929
|
+
console.log(`\n${c.green}✓ Added local MCP server:${c.reset} ${name} ${c.dim}(command=${cmd} args=${JSON.stringify(cmdArgs)})${c.reset}`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
933
|
+
const name = args[1];
|
|
934
|
+
if (!name) {
|
|
935
|
+
console.log(`\n${c.yellow}Usage: /mcp remove <name>${c.reset}`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const { removeLocalMcpServer } = await import('../agent/mcp-registry.js');
|
|
939
|
+
const removed = removeLocalMcpServer(cwd, name);
|
|
940
|
+
if (removed) {
|
|
941
|
+
console.log(`\n${c.green}✓ Removed local MCP server:${c.reset} ${name}`);
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
console.log(`\n${c.yellow}No local MCP server named ${name}${c.reset}`);
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (sub === 'test') {
|
|
949
|
+
const name = args[1];
|
|
950
|
+
if (!name) {
|
|
951
|
+
console.log(`\n${c.yellow}Usage: /mcp test <name>${c.reset}`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const { mergedMcpServers } = await import('../agent/mcp-registry.js');
|
|
955
|
+
const all = mergedMcpServers(cwd);
|
|
956
|
+
const cfg = all[name];
|
|
957
|
+
if (!cfg) {
|
|
958
|
+
console.log(`\n${c.yellow}No MCP server named ${name} (in any source)${c.reset}`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const hasCommand = typeof cfg.command === 'string';
|
|
962
|
+
const hasUrl = typeof cfg.url === 'string';
|
|
963
|
+
if (hasCommand || hasUrl) {
|
|
964
|
+
console.log(`\n${c.green}✓ ${name}${c.reset} — config looks valid (${hasCommand ? 'stdio' : 'sse'})`);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
console.log(`\n${c.red}✗ ${name}${c.reset} — missing 'command' or 'url'`);
|
|
968
|
+
}
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
console.log(`\n${c.yellow}Unknown /mcp subcommand: ${sub}. Use: list | add | remove | test${c.reset}`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
case '/todo': {
|
|
975
|
+
const sub = args[0]?.toLowerCase();
|
|
976
|
+
if (!sub || sub === 'list' || sub === 'ls') {
|
|
977
|
+
const list = loadTodoList(cwd);
|
|
978
|
+
const summary = summariseTodoList(list);
|
|
979
|
+
console.log('');
|
|
980
|
+
console.log(renderTodoList(list));
|
|
981
|
+
if (summary.length > 0) {
|
|
982
|
+
console.log(`\n${c.dim}(${summary})${c.reset}`);
|
|
983
|
+
}
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (sub === 'add') {
|
|
987
|
+
const text = args.slice(1).join(' ').trim();
|
|
988
|
+
if (text.length === 0) {
|
|
989
|
+
console.log(`\n${c.yellow}Usage: /todo add <text>${c.reset}`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const list = addItem(loadTodoList(cwd), { content: text });
|
|
993
|
+
const result = saveTodoList(cwd, list);
|
|
994
|
+
if (!result.ok) {
|
|
995
|
+
console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
console.log(`\n${c.green}✓ Added todo:${c.reset} ${text}`);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (sub === 'done' || sub === 'complete' || sub === 'cancel') {
|
|
1002
|
+
const id = args[1];
|
|
1003
|
+
if (!id) {
|
|
1004
|
+
console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const status = sub === 'cancel' ? 'cancelled' : 'done';
|
|
1008
|
+
let list = loadTodoList(cwd);
|
|
1009
|
+
const exists = list.items.some((it) => it.id === id);
|
|
1010
|
+
if (!exists) {
|
|
1011
|
+
console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
list = setItemStatus(list, { id, status });
|
|
1015
|
+
const result = saveTodoList(cwd, list);
|
|
1016
|
+
if (!result.ok) {
|
|
1017
|
+
console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
console.log(`\n${c.green}✓ Marked ${status}${c.reset}`);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (sub === 'start' || sub === 'progress') {
|
|
1024
|
+
const id = args[1];
|
|
1025
|
+
if (!id) {
|
|
1026
|
+
console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
let list = loadTodoList(cwd);
|
|
1030
|
+
const exists = list.items.some((it) => it.id === id);
|
|
1031
|
+
if (!exists) {
|
|
1032
|
+
console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
list = setItemStatus(list, { id, status: 'in_progress' });
|
|
1036
|
+
const result = saveTodoList(cwd, list);
|
|
1037
|
+
if (!result.ok) {
|
|
1038
|
+
console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
console.log(`\n${c.green}✓ Marked in_progress${c.reset}`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1045
|
+
const id = args[1];
|
|
1046
|
+
if (!id) {
|
|
1047
|
+
console.log(`\n${c.yellow}Usage: /todo remove <id>${c.reset}`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
let list = loadTodoList(cwd);
|
|
1051
|
+
const exists = list.items.some((it) => it.id === id);
|
|
1052
|
+
if (!exists) {
|
|
1053
|
+
console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
list = removeItem(list, { id });
|
|
1057
|
+
const result = saveTodoList(cwd, list);
|
|
1058
|
+
if (!result.ok) {
|
|
1059
|
+
console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
console.log(`\n${c.green}✓ Removed todo${c.reset}`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (sub === 'clear') {
|
|
1066
|
+
const list = loadTodoList(cwd);
|
|
1067
|
+
const kept = list.items.filter((it) => it.status !== 'done' && it.status !== 'cancelled');
|
|
1068
|
+
const result = saveTodoList(cwd, { ...list, items: kept, updatedAt: Date.now() });
|
|
1069
|
+
if (!result.ok) {
|
|
1070
|
+
console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const cleared = list.items.length - kept.length;
|
|
1074
|
+
console.log(`\n${c.green}✓ Cleared ${cleared} completed todo(s)${c.reset}`);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (sub === 'help' || sub === '-h' || sub === '--help') {
|
|
1078
|
+
console.log(`\n${c.bold}Usage: /todo <subcommand>${c.reset}`);
|
|
1079
|
+
console.log(` list List all todo items`);
|
|
1080
|
+
console.log(` add <text> Add a new todo`);
|
|
1081
|
+
console.log(` start <id> Mark a todo as in-progress`);
|
|
1082
|
+
console.log(` done <id> Mark a todo as done`);
|
|
1083
|
+
console.log(` cancel <id> Cancel a todo`);
|
|
1084
|
+
console.log(` remove <id> Remove a todo entirely`);
|
|
1085
|
+
console.log(` clear Remove all done/cancelled todos`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
console.log(`\n${c.yellow}Unknown /todo subcommand "${sub}". Try /todo help.${c.reset}`);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
case '/log':
|
|
1092
|
+
console.log(`\n${git.getRecentCommits(10)}`);
|
|
1093
|
+
return;
|
|
1094
|
+
case '/stats':
|
|
1095
|
+
printStats(stats);
|
|
1096
|
+
{
|
|
1097
|
+
const ctxTokens = conversation.getTotalTokens();
|
|
1098
|
+
const ctxLimit = conversation.getContextLimit();
|
|
1099
|
+
const ctxPct = Math.round((ctxTokens / ctxLimit) * 100);
|
|
1100
|
+
const hasSummary = conversation.getSummary() ? ' (compacted)' : '';
|
|
1101
|
+
console.log(`${c.cyan}${c.bold}📊 Context Window${c.reset}`);
|
|
1102
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
1103
|
+
console.log(` History messages: ${c.bold}${conversation.getMessageCount()}${c.reset}${hasSummary}`);
|
|
1104
|
+
console.log(` Context usage: ${c.bold}${(ctxTokens / 1000).toFixed(0)}k / ${(ctxLimit / 1000).toFixed(0)}k${c.reset} (${ctxPct}%)`);
|
|
1105
|
+
console.log(` Turns: ${c.bold}${conversation.getTurnCount()}${c.reset}`);
|
|
1106
|
+
console.log('');
|
|
1107
|
+
}
|
|
1108
|
+
return;
|
|
1109
|
+
case '/runs': {
|
|
1110
|
+
const runs = listRuns(cwd, 12);
|
|
1111
|
+
console.log(runs.length
|
|
1112
|
+
? `\n${runs.map(run => `${run.id} ${run.status} ${run.task.slice(0, 80)}`).join('\n')}`
|
|
1113
|
+
: '\n(no FixO runs recorded)');
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
case '/show-run':
|
|
1117
|
+
console.log(`\n${showRun(cwd, args[0] ?? '')}`);
|
|
1118
|
+
return;
|
|
1119
|
+
case '/memory':
|
|
1120
|
+
console.log(`\n${readMemory(cwd)}`);
|
|
1121
|
+
return;
|
|
1122
|
+
case '/remember': {
|
|
1123
|
+
const text = args.join(' ').trim();
|
|
1124
|
+
if (!text) {
|
|
1125
|
+
console.log(`\n${c.yellow}Usage: /remember <project fact>${c.reset}`);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
rl.pause();
|
|
1129
|
+
const confirmed = await p.confirm({ message: `Add to project memory: ${text}?`, initialValue: false });
|
|
1130
|
+
rl.resume();
|
|
1131
|
+
if (!p.isCancel(confirmed) && confirmed) {
|
|
1132
|
+
appendMemory(cwd, text);
|
|
1133
|
+
console.log(`\n${c.green}✓ Memory updated${c.reset}`);
|
|
1134
|
+
}
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
case '/forget':
|
|
1138
|
+
rl.pause();
|
|
1139
|
+
{
|
|
1140
|
+
const confirmed = await p.confirm({ message: 'Clear FixO project memory?', initialValue: false });
|
|
1141
|
+
rl.resume();
|
|
1142
|
+
if (!p.isCancel(confirmed) && confirmed) {
|
|
1143
|
+
forgetMemory(cwd);
|
|
1144
|
+
console.log(`\n${c.green}✓ Memory cleared${c.reset}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
case '/doctor':
|
|
1149
|
+
console.log(`\n${doctor(cwd)}`);
|
|
1150
|
+
return;
|
|
1151
|
+
case '/index': {
|
|
1152
|
+
const index = await buildIndex(cwd);
|
|
1153
|
+
workspaceFiles = index.files.map(f => f.path);
|
|
1154
|
+
console.log(`\n${c.green}✓ Indexed ${index.files.length} files${c.reset}`);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
case '/find':
|
|
1158
|
+
console.log(`\n${await findInIndex(cwd, args.join(' '))}`);
|
|
1159
|
+
return;
|
|
1160
|
+
case '/explain':
|
|
1161
|
+
console.log(`\n${await explainIndexedTarget(cwd, args.join(' '))}`);
|
|
1162
|
+
return;
|
|
1163
|
+
case '/review':
|
|
1164
|
+
console.log(`\n${reviewWorkspace(cwd)}`);
|
|
1165
|
+
return;
|
|
1166
|
+
case '/test':
|
|
1167
|
+
console.log(`\n${runProjectTests(cwd)}`);
|
|
1168
|
+
return;
|
|
1169
|
+
case '/fix-tests': {
|
|
1170
|
+
let testResult = runProjectTests(cwd);
|
|
1171
|
+
if (testResult.includes('Status: 0')) {
|
|
1172
|
+
console.log(`\n${c.green}✓ All tests are passing!${c.reset}`);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
let attempt = 1;
|
|
1176
|
+
const maxAttempts = 3;
|
|
1177
|
+
const modifiedFiles = [];
|
|
1178
|
+
while (attempt <= maxAttempts) {
|
|
1179
|
+
console.log(`\n${c.cyan}🔨 [Auto-Fix] Test failure detected (Attempt ${attempt}/${maxAttempts}). Invoking SingleAgent to repair...${c.reset}`);
|
|
1180
|
+
console.log(`${c.dim}${testResult}${c.reset}\n`);
|
|
1181
|
+
const repairTask = `The project tests are failing. Here is the test runner output:\n\n${testResult}\n\nPlease identify the files causing the failure, modify them to fix the issues, verify using the test commands, and ensure they pass.`;
|
|
1182
|
+
const context = {
|
|
1183
|
+
task: repairTask,
|
|
1184
|
+
model: currentModel,
|
|
1185
|
+
cwd,
|
|
1186
|
+
verbose,
|
|
1187
|
+
selectedFiles: [...selectedFiles],
|
|
1188
|
+
systemPromptOverride: projectConfig?.systemPrompt,
|
|
1189
|
+
checkCommand: projectConfig?.checkCommand,
|
|
1190
|
+
policy: projectConfig?.policy ?? config.preferences.policy,
|
|
1191
|
+
mode: 'BUILD',
|
|
1192
|
+
yes: true,
|
|
1193
|
+
};
|
|
1194
|
+
try {
|
|
1195
|
+
const result = await agent.runStreaming(context, conversation, rl);
|
|
1196
|
+
for (const file of result.modifiedFiles) {
|
|
1197
|
+
if (!modifiedFiles.includes(file)) {
|
|
1198
|
+
modifiedFiles.push(file);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
catch (err) {
|
|
1203
|
+
console.log(`\n${c.red}✗ Repair agent failed on attempt ${attempt}: ${err.message || err}${c.reset}`);
|
|
1204
|
+
}
|
|
1205
|
+
testResult = runProjectTests(cwd);
|
|
1206
|
+
if (testResult.includes('Status: 0')) {
|
|
1207
|
+
console.log(`\n${c.green}✓ All tests passed after repair attempt ${attempt}!${c.reset}`);
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
attempt++;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (!testResult.includes('Status: 0')) {
|
|
1215
|
+
console.log(`\n${c.red}✗ Auto-fix failed after ${maxAttempts} attempts. Remaining failures:${c.reset}`);
|
|
1216
|
+
console.log(`${c.dim}${testResult}${c.reset}`);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
// Auto-commit if enabled and changes were made
|
|
1220
|
+
if (config.preferences.autoCommit &&
|
|
1221
|
+
(projectConfig?.autoCommit !== false) &&
|
|
1222
|
+
modifiedFiles.length > 0) {
|
|
1223
|
+
console.log(`\n${c.green}✓ Auto-committing repaired test files...${c.reset}`);
|
|
1224
|
+
git.autoCommit('fix-tests: repair test failures', modifiedFiles);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
case '/fix-ci':
|
|
1230
|
+
console.log(`\n${c.yellow}/fix-ci local mode: paste CI logs into a task or save them to a workspace file, then ask FixO to inspect that file.${c.reset}`);
|
|
1231
|
+
return;
|
|
1232
|
+
case '/plan':
|
|
1233
|
+
{
|
|
1234
|
+
const task = args.join(' ').trim();
|
|
1235
|
+
if (!task) {
|
|
1236
|
+
console.log(`\n${c.yellow}Usage: /plan <task>${c.reset}`);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
const plan = savePlan(cwd, task);
|
|
1240
|
+
console.log(`\n${renderPlan(plan)}`);
|
|
1241
|
+
}
|
|
1242
|
+
return;
|
|
1243
|
+
case '/run-plan': {
|
|
1244
|
+
const dagFile = path.join(cwd, '.fixo', 'last-dag.json');
|
|
1245
|
+
if (fs.existsSync(dagFile)) {
|
|
1246
|
+
try {
|
|
1247
|
+
const { task, dag } = JSON.parse(fs.readFileSync(dagFile, 'utf-8'));
|
|
1248
|
+
console.log(`\n${c.cyan}[Saved Plan] Executing saved subtasks DAG for task: ${c.bold}${task}${c.reset}`);
|
|
1249
|
+
const { AgentPool } = await import('../agent/agent-pool.js');
|
|
1250
|
+
const pool = new AgentPool(3, projectConfig?.maxAttempts ?? 12);
|
|
1251
|
+
const context = {
|
|
1252
|
+
task,
|
|
1253
|
+
model: currentModel,
|
|
1254
|
+
cwd,
|
|
1255
|
+
verbose,
|
|
1256
|
+
selectedFiles: [...selectedFiles],
|
|
1257
|
+
systemPromptOverride: projectConfig?.systemPrompt,
|
|
1258
|
+
checkCommand: projectConfig?.checkCommand,
|
|
1259
|
+
policy: projectConfig?.policy ?? config.preferences.policy,
|
|
1260
|
+
mode: currentMode,
|
|
1261
|
+
};
|
|
1262
|
+
const success = await pool.execute(context, dag);
|
|
1263
|
+
if (success) {
|
|
1264
|
+
console.log(`\n${c.green}✓ Successfully completed complex task via parallel agents.${c.reset}`);
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
|
|
1268
|
+
if (git.isGitRepo()) {
|
|
1269
|
+
console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
|
|
1270
|
+
git.discardUncommittedChanges();
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
console.log(`\n${c.red}✗ Failed to run saved DAG: ${err.message}${c.reset}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const plan = loadPlan(cwd);
|
|
1280
|
+
if (!plan) {
|
|
1281
|
+
console.log(`\n${c.yellow}No saved plan or DAG. Generate one with /plan <task> or run a complex task in PLAN mode.${c.reset}`);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
console.log(`\n${c.dim}Executing saved plan task: ${plan.task}${c.reset}`);
|
|
1285
|
+
await handleInput(plan.task);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
case '/mode': {
|
|
1289
|
+
rl.pause();
|
|
1290
|
+
const selected = await p.select({
|
|
1291
|
+
message: 'Select execution mode:',
|
|
1292
|
+
options: [
|
|
1293
|
+
{ value: 'PLAN', label: 'PLAN Mode (Read-only, dry-run simulation)' },
|
|
1294
|
+
{ value: 'BUILD', label: 'BUILD Mode (Writing & modifying allowed)' },
|
|
1295
|
+
{ value: 'EXPLORE', label: 'EXPLORE Mode (Code exploration & LSP, no modifying)' },
|
|
1296
|
+
{ value: 'SCOUT', label: 'SCOUT Mode (Web search & fetch only)' },
|
|
1297
|
+
],
|
|
1298
|
+
initialValue: currentMode,
|
|
1299
|
+
});
|
|
1300
|
+
rl.resume();
|
|
1301
|
+
if (!p.isCancel(selected) && selected) {
|
|
1302
|
+
currentMode = selected;
|
|
1303
|
+
console.log(`\n${c.green}✓ Execution mode set to: ${c.bold}${currentMode}${c.reset}`);
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
console.log(`\n${c.dim}Execution mode remains: ${c.cyan}${currentMode}${c.reset}`);
|
|
1307
|
+
}
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
case '/session': {
|
|
1311
|
+
const sub = args[0];
|
|
1312
|
+
const { SessionManager } = await import('../agent/conversation.js');
|
|
1313
|
+
if (sub === 'list') {
|
|
1314
|
+
const list = SessionManager.listSessions();
|
|
1315
|
+
if (list.length === 0) {
|
|
1316
|
+
console.log(`\n${c.dim}No saved sessions found.${c.reset}`);
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
console.log(`\n${c.cyan}${c.bold}Saved Sessions:${c.reset}`);
|
|
1320
|
+
for (const s of list) {
|
|
1321
|
+
const date = new Date(s.timestamp).toLocaleString();
|
|
1322
|
+
console.log(` ${c.cyan}${s.sessionId}${c.reset} - ${c.bold}${s.model}${c.reset} (${s.messageCount} msgs)`);
|
|
1323
|
+
console.log(` ${c.dim}Created: ${date} | Tokens: ${s.totalTokens.toLocaleString()}${c.reset}`);
|
|
1324
|
+
if (s.summary) {
|
|
1325
|
+
console.log(` ${c.dim}Summary: ${s.summary.slice(0, 80)}...${c.reset}`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
else if (sub === 'load') {
|
|
1331
|
+
const uuid = args[1];
|
|
1332
|
+
if (!uuid) {
|
|
1333
|
+
console.log(`\n${c.yellow}Usage: /session load <uuid>${c.reset}`);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
const data = SessionManager.loadSession(uuid);
|
|
1338
|
+
conversation.clear();
|
|
1339
|
+
conversation.importHistory(data.history);
|
|
1340
|
+
conversation.setSummary(data.summary || '');
|
|
1341
|
+
currentModel = data.model;
|
|
1342
|
+
conversation.setContextLimit(currentModel);
|
|
1343
|
+
sessionModifiedFiles = data.modifiedFiles || [];
|
|
1344
|
+
currentSessionId = data.sessionId;
|
|
1345
|
+
stats.totalPromptTokens = data.tokenUsage?.prompt_tokens || 0;
|
|
1346
|
+
stats.totalCompletionTokens = data.tokenUsage?.completion_tokens || 0;
|
|
1347
|
+
console.log(`\n${c.green}✓ Session restored successfully: ${c.bold}${uuid}${c.reset}`);
|
|
1348
|
+
console.log(`${c.dim} Model set to: ${c.cyan}${currentModel}${c.reset}`);
|
|
1349
|
+
}
|
|
1350
|
+
catch (err) {
|
|
1351
|
+
console.log(`\n${c.red}✗ Failed to load session: ${err.message}${c.reset}`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
else if (sub === 'new') {
|
|
1355
|
+
conversation.clear();
|
|
1356
|
+
sessionModifiedFiles = [];
|
|
1357
|
+
stats.totalPromptTokens = 0;
|
|
1358
|
+
stats.totalCompletionTokens = 0;
|
|
1359
|
+
stats.totalToolCalls = 0;
|
|
1360
|
+
stats.totalTasks = 0;
|
|
1361
|
+
stats.totalDurationMs = 0;
|
|
1362
|
+
const { randomUUID } = await import('node:crypto');
|
|
1363
|
+
currentSessionId = randomUUID();
|
|
1364
|
+
SessionManager.saveSession(conversation, currentModel, sessionModifiedFiles, {
|
|
1365
|
+
prompt_tokens: stats.totalPromptTokens,
|
|
1366
|
+
completion_tokens: stats.totalCompletionTokens,
|
|
1367
|
+
total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
|
|
1368
|
+
}, currentSessionId);
|
|
1369
|
+
console.log(`\n${c.green}✓ Active conversation memory purged. New session initialized: ${c.bold}${currentSessionId}${c.reset}`);
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
console.log(`\n${c.yellow}Usage: /session [list | load <uuid> | new]${c.reset}`);
|
|
1373
|
+
}
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
case '/providers': {
|
|
1377
|
+
const sub = args[0];
|
|
1378
|
+
// ── Interactive flow (bare `/providers`): mirrors the
|
|
1379
|
+
// /model picker shape. The user picks a provider, then
|
|
1380
|
+
// an action, then enters a masked API key via p.password
|
|
1381
|
+
// when the action is add/update. The legacy text routes
|
|
1382
|
+
// below remain unchanged for muscle-memory + scripting.
|
|
1383
|
+
if (!sub) {
|
|
1384
|
+
rl.pause();
|
|
1385
|
+
const pickedProvider = await p.select({
|
|
1386
|
+
message: 'Select an AI provider:',
|
|
1387
|
+
options: PROVIDER_REGISTRY.map(def => ({
|
|
1388
|
+
value: def.name,
|
|
1389
|
+
label: def.displayName,
|
|
1390
|
+
hint: ProvidersManager.has(def.name) ? '[key ✓]' : '[no key]',
|
|
1391
|
+
})),
|
|
1392
|
+
});
|
|
1393
|
+
rl.resume();
|
|
1394
|
+
if (p.isCancel(pickedProvider)) {
|
|
1395
|
+
console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const def = ProvidersManager.getDefinition(pickedProvider);
|
|
1399
|
+
if (!def) {
|
|
1400
|
+
console.log(`\n${c.red}✗ Unknown provider: ${pickedProvider}${c.reset}`);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const hasKey = ProvidersManager.has(def.name);
|
|
1404
|
+
rl.pause();
|
|
1405
|
+
const action = await p.select({
|
|
1406
|
+
message: `${def.displayName} — choose an action:`,
|
|
1407
|
+
options: [
|
|
1408
|
+
{ value: 'add', label: hasKey ? 'Update API key' : 'Add API key' },
|
|
1409
|
+
{ value: 'test', label: 'Test connection', hint: hasKey ? '' : 'requires a key' },
|
|
1410
|
+
{ value: 'remove', label: 'Remove API key', hint: hasKey ? '' : 'no key configured' },
|
|
1411
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
1412
|
+
],
|
|
1413
|
+
});
|
|
1414
|
+
rl.resume();
|
|
1415
|
+
if (p.isCancel(action) || action === 'cancel') {
|
|
1416
|
+
console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
if (action === 'add') {
|
|
1420
|
+
console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
|
|
1421
|
+
rl.pause();
|
|
1422
|
+
const key = await p.password({
|
|
1423
|
+
message: `Enter your ${def.displayName} API key:`,
|
|
1424
|
+
validate: v => !v?.trim() ? 'API key is required' : undefined,
|
|
1425
|
+
});
|
|
1426
|
+
rl.resume();
|
|
1427
|
+
if (p.isCancel(key)) {
|
|
1428
|
+
console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
ProvidersManager.add(def.name, key);
|
|
1432
|
+
console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
|
|
1433
|
+
await refreshModelsForProvider(def.name);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (action === 'remove') {
|
|
1437
|
+
if (!hasKey) {
|
|
1438
|
+
console.log(`\n${c.yellow}No key configured for ${def.displayName}.${c.reset}`);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
rl.pause();
|
|
1442
|
+
const confirmed = await p.confirm({
|
|
1443
|
+
message: `Remove API key for ${def.displayName}?`,
|
|
1444
|
+
initialValue: false,
|
|
1445
|
+
});
|
|
1446
|
+
rl.resume();
|
|
1447
|
+
if (!p.isCancel(confirmed) && confirmed) {
|
|
1448
|
+
const removed = ProvidersManager.remove(def.name);
|
|
1449
|
+
console.log(removed
|
|
1450
|
+
? `\n${c.green}✓ Removed API key for ${def.displayName}.${c.reset}`
|
|
1451
|
+
: `\n${c.yellow}No key found for provider: ${def.name}${c.reset}`);
|
|
1452
|
+
}
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (action === 'test') {
|
|
1456
|
+
if (!hasKey) {
|
|
1457
|
+
console.log(`\n${c.yellow}No key configured for ${def.displayName}. Add one first.${c.reset}`);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
console.log(`\n${c.dim}Testing connection to ${def.displayName} via live /models fetch…${c.reset}`);
|
|
1461
|
+
await refreshModelsForProvider(def.name);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (sub === 'list') {
|
|
1467
|
+
const list = ProvidersManager.list();
|
|
1468
|
+
if (list.length === 0) {
|
|
1469
|
+
console.log(`\n${c.yellow}No providers configured.${c.reset}`);
|
|
1470
|
+
console.log(`${c.dim} Use /providers add <name> to connect a provider (e.g. /providers add groq)${c.reset}`);
|
|
1471
|
+
console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
console.log(`\n${c.bold}${c.cyan}Connected Providers${c.reset}`);
|
|
1475
|
+
console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
|
|
1476
|
+
for (const entry of list) {
|
|
1477
|
+
const addedDate = new Date(entry.addedAt).toLocaleDateString();
|
|
1478
|
+
console.log(` ${c.cyan}${entry.name.padEnd(14)}${c.reset}${c.bold}${entry.displayName.padEnd(22)}${c.reset}${c.dim}${entry.maskedKey} (added ${addedDate})${c.reset}`);
|
|
1479
|
+
}
|
|
1480
|
+
console.log(`\n${c.dim} Use /providers remove <name> to remove a key.${c.reset}`);
|
|
1481
|
+
console.log(`${c.dim} Use /providers test <name> to verify a connection.${c.reset}`);
|
|
1482
|
+
}
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
if (sub === 'add') {
|
|
1486
|
+
const name = args[1]?.toLowerCase();
|
|
1487
|
+
if (!name) {
|
|
1488
|
+
console.log(`\n${c.yellow}Usage: /providers add <provider-name>${c.reset}`);
|
|
1489
|
+
console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const def = ProvidersManager.getDefinition(name);
|
|
1493
|
+
if (!def) {
|
|
1494
|
+
console.log(`\n${c.red}✗ Unknown provider: ${name}${c.reset}`);
|
|
1495
|
+
console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
console.log(`\n${c.cyan}${c.bold}Connecting to ${def.displayName}${c.reset}`);
|
|
1499
|
+
console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
|
|
1500
|
+
rl.pause();
|
|
1501
|
+
const apiKeyInput = await p.text({
|
|
1502
|
+
message: `Enter your ${def.displayName} API key:`,
|
|
1503
|
+
placeholder: 'sk-... or gsk_...',
|
|
1504
|
+
validate: v => !v.trim() ? 'API key is required' : undefined,
|
|
1505
|
+
});
|
|
1506
|
+
rl.resume();
|
|
1507
|
+
if (p.isCancel(apiKeyInput)) {
|
|
1508
|
+
console.log(`\n${c.dim}Provider add cancelled.${c.reset}`);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
ProvidersManager.add(name, apiKeyInput);
|
|
1512
|
+
console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
|
|
1513
|
+
console.log(`${c.dim} FixO will now route ${def.displayName} requests directly (bypassing the SaaS proxy).${c.reset}`);
|
|
1514
|
+
await refreshModelsForProvider(name);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (sub === 'remove') {
|
|
1518
|
+
const name = args[1]?.toLowerCase();
|
|
1519
|
+
if (!name) {
|
|
1520
|
+
console.log(`\n${c.yellow}Usage: /providers remove <name>${c.reset}`);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
rl.pause();
|
|
1524
|
+
const confirmed = await p.confirm({ message: `Remove API key for ${name}?`, initialValue: false });
|
|
1525
|
+
rl.resume();
|
|
1526
|
+
if (!p.isCancel(confirmed) && confirmed) {
|
|
1527
|
+
const removed = ProvidersManager.remove(name);
|
|
1528
|
+
console.log(removed
|
|
1529
|
+
? `\n${c.green}✓ Removed API key for ${name}.${c.reset}`
|
|
1530
|
+
: `\n${c.yellow}No key found for provider: ${name}${c.reset}`);
|
|
1531
|
+
}
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (sub === 'test') {
|
|
1535
|
+
const name = args[1]?.toLowerCase();
|
|
1536
|
+
if (!name) {
|
|
1537
|
+
console.log(`\n${c.yellow}Usage: /providers test <name>${c.reset}`);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const directConf = ProvidersManager.getDirectConfig(name);
|
|
1541
|
+
if (!directConf) {
|
|
1542
|
+
console.log(`\n${c.yellow}No key configured for ${name}. Use /providers add ${name} first.${c.reset}`);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
console.log(`\n${c.dim}Testing connection to ${directConf.displayName} (${directConf.baseUrl})...${c.reset}`);
|
|
1546
|
+
try {
|
|
1547
|
+
const testHeaders = {
|
|
1548
|
+
'Authorization': `Bearer ${directConf.apiKey}`,
|
|
1549
|
+
};
|
|
1550
|
+
if (name === 'zen' || name === 'openrouter') {
|
|
1551
|
+
testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
|
|
1552
|
+
testHeaders['X-Title'] = 'opencode';
|
|
1553
|
+
}
|
|
1554
|
+
else if (name === 'nvidia') {
|
|
1555
|
+
testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
|
|
1556
|
+
testHeaders['X-Title'] = 'opencode';
|
|
1557
|
+
testHeaders['X-BILLING-INVOKE-ORIGIN'] = 'OpenCode';
|
|
1558
|
+
}
|
|
1559
|
+
else if (name === 'cerebras') {
|
|
1560
|
+
testHeaders['X-Cerebras-3rd-Party-Integration'] = 'opencode';
|
|
1561
|
+
}
|
|
1562
|
+
const resp = await fetch(`${directConf.baseUrl}/models`, {
|
|
1563
|
+
headers: testHeaders,
|
|
1564
|
+
signal: AbortSignal.timeout(8000),
|
|
1565
|
+
});
|
|
1566
|
+
if (resp.ok) {
|
|
1567
|
+
console.log(`${c.green}✓ Connection to ${directConf.displayName} successful! (HTTP ${resp.status})${c.reset}`);
|
|
1568
|
+
// Warm the cache so /model picker shows live IDs.
|
|
1569
|
+
await refreshModelsForProvider(name);
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1572
|
+
const text = await resp.text().catch(() => '');
|
|
1573
|
+
console.log(`${c.red}✗ ${directConf.displayName} returned HTTP ${resp.status}${text ? ': ' + text.slice(0, 100) : ''}${c.reset}`);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
catch (err) {
|
|
1577
|
+
console.log(`${c.red}✗ Connection failed: ${err.message}${c.reset}`);
|
|
1578
|
+
}
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
console.log(`\n${c.yellow}Usage: /providers [list | add <name> | remove <name> | test <name>]${c.reset}`);
|
|
1582
|
+
console.log(`${c.dim} Available providers: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
case '/compact': {
|
|
1586
|
+
const msgCount = conversation.getMessageCount();
|
|
1587
|
+
if (msgCount === 0) {
|
|
1588
|
+
console.log(`\n${c.dim}Nothing to compact — conversation is empty.${c.reset}`);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const tokensBefore = conversation.getTotalTokens();
|
|
1592
|
+
const contextLimit = conversation.getContextLimit();
|
|
1593
|
+
console.log(`\n${c.cyan}[Compact] Summarising ${msgCount} messages to free context tokens...${c.reset}`);
|
|
1594
|
+
console.log(`${c.dim} Current context: ${(tokensBefore / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k tokens${c.reset}`);
|
|
1595
|
+
try {
|
|
1596
|
+
const compacted = await conversation.compact(agent.getClient(), currentModel);
|
|
1597
|
+
if (compacted) {
|
|
1598
|
+
const info = conversation.getLastCompactionInfo();
|
|
1599
|
+
const tokensAfter = conversation.getTotalTokens();
|
|
1600
|
+
console.log(`${c.green}✓ Compacted: ${info?.messagesBefore ?? msgCount} messages → summary + ${conversation.getMessageCount()} recent messages.${c.reset}`);
|
|
1601
|
+
console.log(`${c.dim} Context: ${(tokensBefore / 1000).toFixed(0)}k → ${(tokensAfter / 1000).toFixed(0)}k tokens (~${((info?.tokensFreed ?? 0) / 1000).toFixed(0)}k freed).${c.reset}`);
|
|
1602
|
+
}
|
|
1603
|
+
else {
|
|
1604
|
+
console.log(`${c.dim}Not enough messages to compact (need more than 4 messages).${c.reset}`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
catch (err) {
|
|
1608
|
+
console.log(`${c.red}✗ Compact failed: ${err.message}${c.reset}`);
|
|
1609
|
+
}
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
case '/snapshot': {
|
|
1613
|
+
const label = args.join(' ').trim() || `snapshot-${Date.now()}`;
|
|
1614
|
+
if (!git.isGitRepo()) {
|
|
1615
|
+
console.log(`\n${c.yellow}⚠ Not a git repository — cannot create snapshot.${c.reset}`);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const hash = git.createSnapshot(label);
|
|
1619
|
+
if (hash) {
|
|
1620
|
+
console.log(`\n${c.green}✓ Workspace snapshot created: ${c.bold}${hash}${c.reset}${c.dim} (label: ${label})${c.reset}`);
|
|
1621
|
+
console.log(`${c.dim} Use /undo or git revert to roll back to this point.${c.reset}`);
|
|
1622
|
+
}
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
case '/skills': {
|
|
1626
|
+
const { skillsManager } = await import('../agent/skills.js');
|
|
1627
|
+
const list = skillsManager.getSkills();
|
|
1628
|
+
if (list.length === 0) {
|
|
1629
|
+
console.log(`\n${c.dim}No skills registered. Register skill profiles by adding SKILL.md under ~/.fixocli/skills/<name>/ or .fixocli/skills/<name>/${c.reset}`);
|
|
1630
|
+
}
|
|
1631
|
+
else {
|
|
1632
|
+
console.log(`\n${c.cyan}${c.bold}Registered Skills:${c.reset}`);
|
|
1633
|
+
for (const skill of list) {
|
|
1634
|
+
console.log(` - ${c.bold}${skill.name}${c.reset}${skill.description ? `: ${skill.description}` : ''} ${c.dim}(${skill.location})${c.reset}`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
case '/theme':
|
|
1640
|
+
case '/variant': {
|
|
1641
|
+
const { themeMode, setThemeMode } = await import('./colors.js');
|
|
1642
|
+
const newMode = themeMode === 'dark' ? 'inverted' : 'dark';
|
|
1643
|
+
setThemeMode(newMode);
|
|
1644
|
+
console.log(`\n${c.cyan}✓ Theme set to: ${newMode === 'dark' ? 'Dark Void Minimalist' : 'High-Contrast Inverted'}${c.reset}`);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
case '/telemetry': {
|
|
1648
|
+
const sub = args[0]?.toLowerCase();
|
|
1649
|
+
if (sub === 'on' || sub === 'enable') {
|
|
1650
|
+
config.preferences.telemetry = true;
|
|
1651
|
+
saveConfig(config);
|
|
1652
|
+
console.log(`\n${c.green}✓ Telemetry enabled${c.reset}`);
|
|
1653
|
+
}
|
|
1654
|
+
else if (sub === 'off' || sub === 'disable') {
|
|
1655
|
+
config.preferences.telemetry = false;
|
|
1656
|
+
saveConfig(config);
|
|
1657
|
+
console.log(`\n${c.green}✓ Telemetry disabled${c.reset}`);
|
|
1658
|
+
}
|
|
1659
|
+
else {
|
|
1660
|
+
console.log(`\n${c.dim}Telemetry is currently ${config.preferences.telemetry ? `${c.green}ON${c.reset}${c.dim}` : `${c.red}OFF${c.reset}${c.dim}`}. Usage: /telemetry on|off${c.reset}`);
|
|
1661
|
+
}
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
default:
|
|
1665
|
+
console.log(`\n${c.yellow}Unknown command: ${cmd}. Type /help for available commands.${c.reset}`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
// ─── Shell commands (! prefix) ───
|
|
1670
|
+
if (input.startsWith('!')) {
|
|
1671
|
+
const cmd = input.slice(1).trim();
|
|
1672
|
+
if (!cmd)
|
|
1673
|
+
return;
|
|
1674
|
+
const check = checkPermission('run_command', { command: cmd }, process.cwd(), config.preferences.policy ?? 'shell-confirm');
|
|
1675
|
+
if (check.decision === 'deny') {
|
|
1676
|
+
console.log(`\n${c.red}✗ ${check.reason}${c.reset}`);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (check.decision === 'ask') {
|
|
1680
|
+
rl.pause();
|
|
1681
|
+
const confirmed = await p.confirm({
|
|
1682
|
+
message: `Allow execution of local shell command: ${c.cyan}${cmd}${c.reset}? (${check.reason})`,
|
|
1683
|
+
initialValue: false,
|
|
1684
|
+
});
|
|
1685
|
+
rl.resume();
|
|
1686
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
1687
|
+
console.log(`\n${c.cyan} ⚠ Execution cancelled.${c.reset}`);
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
console.log(`${c.dim}⚙️ Running: ${cmd}${c.reset}`);
|
|
1692
|
+
try {
|
|
1693
|
+
const { spawnSync } = await import('child_process');
|
|
1694
|
+
const result = spawnSync(cmd, {
|
|
1695
|
+
shell: true,
|
|
1696
|
+
cwd,
|
|
1697
|
+
encoding: 'utf-8',
|
|
1698
|
+
timeout: 30_000,
|
|
1699
|
+
maxBuffer: 1024 * 1024,
|
|
1700
|
+
env: redactedEnv(),
|
|
1701
|
+
});
|
|
1702
|
+
const output = redactSecrets([result.stdout ?? '', result.stderr ?? ''].filter(Boolean).join('\n'));
|
|
1703
|
+
if (output.trim())
|
|
1704
|
+
console.log(output);
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
if (error.stdout)
|
|
1708
|
+
console.log(error.stdout);
|
|
1709
|
+
if (error.stderr)
|
|
1710
|
+
console.error(`${c.red}${error.stderr}${c.reset}`);
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
// ─── Agent task ───
|
|
1715
|
+
// Format any paths in the input for display
|
|
1716
|
+
const displayInput = formatInputPaths(input, cwd);
|
|
1717
|
+
if (displayInput !== input) {
|
|
1718
|
+
// Re-display with highlighted paths
|
|
1719
|
+
process.stdout.write(`\x1b[1A\x1b[2K`); // Move up and clear line
|
|
1720
|
+
console.log(`${C.LAVA}›${C.RESET} ${displayInput}`);
|
|
1721
|
+
}
|
|
1722
|
+
// Extract any file paths from input for automatic pinning
|
|
1723
|
+
const pathsInInput = extractFilePaths(input, cwd);
|
|
1724
|
+
const dirtyBefore = git.isGitRepo() ? git.getDirtyFiles() : [];
|
|
1725
|
+
const context = {
|
|
1726
|
+
task: input,
|
|
1727
|
+
model: currentModel,
|
|
1728
|
+
cwd,
|
|
1729
|
+
verbose,
|
|
1730
|
+
selectedFiles: [...selectedFiles, ...pathsInInput],
|
|
1731
|
+
systemPromptOverride: projectConfig?.systemPrompt,
|
|
1732
|
+
checkCommand: projectConfig?.checkCommand,
|
|
1733
|
+
policy: projectConfig?.policy ?? config.preferences.policy,
|
|
1734
|
+
mode: currentMode,
|
|
1735
|
+
pendingAttachments: pendingAttachments.length > 0 ? [...pendingAttachments] : undefined,
|
|
1736
|
+
};
|
|
1737
|
+
// Drain the queue — attachments are one-shot. The agent has its
|
|
1738
|
+
// own copy via context above.
|
|
1739
|
+
pendingAttachments = [];
|
|
1740
|
+
const classification = classifyComplexityHeuristic(input);
|
|
1741
|
+
let result;
|
|
1742
|
+
const startTime = Date.now();
|
|
1743
|
+
if (classification.complexity === 'complex') {
|
|
1744
|
+
console.log(`\n${c.cyan}[Routing Engine] Complex task detected (${classification.reason}). Routing to Orchestrator...${c.reset}`);
|
|
1745
|
+
try {
|
|
1746
|
+
const { Orchestrator } = await import('../agent/orchestrator.js');
|
|
1747
|
+
const { AgentPool } = await import('../agent/agent-pool.js');
|
|
1748
|
+
console.log(`\n${c.cyan}[Orchestrator] Generating plan for complex task...${c.reset}`);
|
|
1749
|
+
const orchestrator = new Orchestrator(verbose);
|
|
1750
|
+
const dag = await orchestrator.plan(context);
|
|
1751
|
+
// Render planned phases in high contrast box
|
|
1752
|
+
const width = 60;
|
|
1753
|
+
const borderTop = `┌${'─'.repeat(width)}┐`;
|
|
1754
|
+
const borderBottom = `└${'─'.repeat(width)}┘`;
|
|
1755
|
+
console.log(`\n${c.cyan}${borderTop}${c.reset}`);
|
|
1756
|
+
console.log(`${c.cyan}│${c.reset} ${c.bold}Planned Subtask Phases (Complex Task decomposition):${c.reset}${' '.repeat(width - 52)}${c.cyan}│${c.reset}`);
|
|
1757
|
+
console.log(`${c.cyan}├${'─'.repeat(width)}┤${c.reset}`);
|
|
1758
|
+
for (const sub of dag.subtasks) {
|
|
1759
|
+
const deps = sub.dependencies.length > 0 ? ` (deps: ${sub.dependencies.join(', ')})` : '';
|
|
1760
|
+
const lineStr = ` - [${sub.persona.toUpperCase()}] ${sub.title}${deps}`;
|
|
1761
|
+
const pad = Math.max(0, width - lineStr.length - 4);
|
|
1762
|
+
console.log(`${c.cyan}│${c.reset} ${c.bold}${lineStr}${c.reset}${' '.repeat(pad)} ${c.cyan}│${c.reset}`);
|
|
1763
|
+
}
|
|
1764
|
+
console.log(`${c.cyan}${borderBottom}${c.reset}\n`);
|
|
1765
|
+
// Save the DAG to .fixo/last-dag.json
|
|
1766
|
+
const fixoDir = path.join(cwd, '.fixo');
|
|
1767
|
+
fs.mkdirSync(fixoDir, { recursive: true });
|
|
1768
|
+
fs.writeFileSync(path.join(fixoDir, 'last-dag.json'), JSON.stringify({ task: input, dag }, null, 2), 'utf-8');
|
|
1769
|
+
if (currentMode === 'PLAN') {
|
|
1770
|
+
console.log(`${c.green}✓ Plan generated and saved successfully.${c.reset}`);
|
|
1771
|
+
console.log(`${c.dim} To execute this plan, switch to BUILD mode (type /mode build or hit [TAB]) and run: /run-plan${c.reset}\n`);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const budgetLimit = projectConfig?.maxAttempts ?? 12;
|
|
1775
|
+
const pool = new AgentPool(3, budgetLimit);
|
|
1776
|
+
console.log(`\n${c.cyan}[Agent Pool] Executing DAG of subtasks (concurrency limit: 3, budget: ${budgetLimit} tool calls)...${c.reset}`);
|
|
1777
|
+
const success = await pool.execute(context, dag);
|
|
1778
|
+
const durationMs = Date.now() - startTime;
|
|
1779
|
+
const totalPromptTokens = orchestrator.tokensUsed.prompt_tokens + pool.tokensUsed.prompt_tokens;
|
|
1780
|
+
const totalCompletionTokens = orchestrator.tokensUsed.completion_tokens + pool.tokensUsed.completion_tokens;
|
|
1781
|
+
// Find modified files to report
|
|
1782
|
+
const { getModifiedFiles, getBranchPoint } = await import('../agent/worker-agent.js');
|
|
1783
|
+
const relativeModified = getModifiedFiles(cwd, getBranchPoint(cwd));
|
|
1784
|
+
const modifiedFiles = relativeModified.map(f => path.resolve(cwd, f));
|
|
1785
|
+
if (!success) {
|
|
1786
|
+
console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
|
|
1787
|
+
if (git.isGitRepo()) {
|
|
1788
|
+
console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
|
|
1789
|
+
git.discardUncommittedChanges();
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
result = {
|
|
1793
|
+
success,
|
|
1794
|
+
response: success
|
|
1795
|
+
? 'Successfully completed complex task via parallel agents.'
|
|
1796
|
+
: 'Failed to complete all complex subtasks.',
|
|
1797
|
+
modifiedFiles,
|
|
1798
|
+
tokensUsed: {
|
|
1799
|
+
prompt_tokens: totalPromptTokens,
|
|
1800
|
+
completion_tokens: totalCompletionTokens,
|
|
1801
|
+
total_tokens: totalPromptTokens + totalCompletionTokens
|
|
1802
|
+
},
|
|
1803
|
+
toolCallCount: pool.toolCallCount,
|
|
1804
|
+
durationMs,
|
|
1805
|
+
model: context.model,
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
catch (err) {
|
|
1809
|
+
console.error(`\n${c.red}✗ Orchestrated execution failed: ${err.message || err}${c.reset}`);
|
|
1810
|
+
if (git.isGitRepo()) {
|
|
1811
|
+
console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to error...${c.reset}`);
|
|
1812
|
+
git.discardUncommittedChanges();
|
|
1813
|
+
}
|
|
1814
|
+
const durationMs = Date.now() - startTime;
|
|
1815
|
+
result = {
|
|
1816
|
+
success: false,
|
|
1817
|
+
response: `Orchestrated run failed: ${err.message || err}`,
|
|
1818
|
+
modifiedFiles: [],
|
|
1819
|
+
tokensUsed: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
1820
|
+
toolCallCount: 0,
|
|
1821
|
+
durationMs,
|
|
1822
|
+
model: context.model,
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
console.log(`\n${c.cyan}[Routing Engine] Simple task detected (${classification.reason}). Routing to SingleAgent...${c.reset}`);
|
|
1828
|
+
result = await agent.runStreaming(context, conversation, rl);
|
|
1829
|
+
}
|
|
1830
|
+
// Print result summary
|
|
1831
|
+
console.log('');
|
|
1832
|
+
const modelPart = result.model ? `${result.model} · ` : '';
|
|
1833
|
+
const tokenInfo = `${c.dim}${modelPart}${result.tokensUsed.total_tokens} tokens · ${result.toolCallCount} tool calls · ${(result.durationMs / 1000).toFixed(1)}s${c.reset}`;
|
|
1834
|
+
console.log(tokenInfo);
|
|
1835
|
+
// Auto-commit if enabled
|
|
1836
|
+
if (config.preferences.autoCommit &&
|
|
1837
|
+
(projectConfig?.autoCommit !== false) &&
|
|
1838
|
+
result.modifiedFiles.length > 0) {
|
|
1839
|
+
const gitModified = result.modifiedFiles.map(f => guard.relative(f));
|
|
1840
|
+
const preExistingEdits = gitModified.filter(f => dirtyBefore.includes(f));
|
|
1841
|
+
let allowed = true;
|
|
1842
|
+
if (preExistingEdits.length > 0) {
|
|
1843
|
+
rl.pause();
|
|
1844
|
+
const confirmed = await p.confirm({
|
|
1845
|
+
message: `The agent modified files with pre-existing uncommitted edits: ${preExistingEdits.join(', ')}. Allow auto-commit?`,
|
|
1846
|
+
initialValue: false,
|
|
1847
|
+
});
|
|
1848
|
+
rl.resume();
|
|
1849
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
1850
|
+
allowed = false;
|
|
1851
|
+
console.log(`\n${c.yellow} ⚠ Auto-commit skipped due to pre-existing edits.${c.reset}`);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (allowed) {
|
|
1855
|
+
git.autoCommit(input, result.modifiedFiles);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// Update stats
|
|
1859
|
+
stats.totalPromptTokens += result.tokensUsed.prompt_tokens;
|
|
1860
|
+
stats.totalCompletionTokens += result.tokensUsed.completion_tokens;
|
|
1861
|
+
stats.totalToolCalls += result.toolCallCount;
|
|
1862
|
+
stats.totalTasks++;
|
|
1863
|
+
stats.totalDurationMs += result.durationMs;
|
|
1864
|
+
// Token budget warning → replaced with auto-compact
|
|
1865
|
+
const currentContextTokens = conversation.getTotalTokens();
|
|
1866
|
+
const contextLimit = conversation.getContextLimit();
|
|
1867
|
+
const contextPct = Math.round((currentContextTokens / contextLimit) * 100);
|
|
1868
|
+
// Auto-compact after each turn if context is getting large
|
|
1869
|
+
if (conversation.shouldCompact()) {
|
|
1870
|
+
console.log(`\n${c.yellow}🔄 Context at ${contextPct}% (${(currentContextTokens / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k) — auto-compacting...${c.reset}`);
|
|
1871
|
+
try {
|
|
1872
|
+
const compacted = await conversation.compact(agent.getClient(), currentModel);
|
|
1873
|
+
if (compacted) {
|
|
1874
|
+
const info = conversation.getLastCompactionInfo();
|
|
1875
|
+
const newTokens = conversation.getTotalTokens();
|
|
1876
|
+
console.log(`${c.green}✓ Compacted: ${info?.messagesBefore ?? '?'} messages → summary + ${conversation.getMessageCount()} recent. ${(currentContextTokens / 1000).toFixed(0)}k → ${(newTokens / 1000).toFixed(0)}k tokens.${c.reset}`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
catch (err) {
|
|
1880
|
+
// Don't let compaction errors crash the REPL
|
|
1881
|
+
console.log(`${c.dim}[Context] Auto-compact failed, continuing with current context.${c.reset}`);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
else if (contextPct > 50) {
|
|
1885
|
+
console.log(`\n${c.dim}📊 Context: ${(currentContextTokens / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k tokens (${contextPct}%)${c.reset}`);
|
|
1886
|
+
}
|
|
1887
|
+
// Save stateful session persistence
|
|
1888
|
+
try {
|
|
1889
|
+
const { SessionManager } = await import('../agent/conversation.js');
|
|
1890
|
+
// Merge modified files from this run
|
|
1891
|
+
for (const file of result.modifiedFiles) {
|
|
1892
|
+
if (!sessionModifiedFiles.includes(file)) {
|
|
1893
|
+
sessionModifiedFiles.push(file);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
SessionManager.saveSession(conversation, currentModel, sessionModifiedFiles, {
|
|
1897
|
+
prompt_tokens: stats.totalPromptTokens,
|
|
1898
|
+
completion_tokens: stats.totalCompletionTokens,
|
|
1899
|
+
total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
|
|
1900
|
+
}, currentSessionId);
|
|
1901
|
+
}
|
|
1902
|
+
catch (err) {
|
|
1903
|
+
// Ignore session save errors
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
// Start the loop
|
|
1907
|
+
promptForInput();
|
|
1908
|
+
}
|
|
1909
|
+
/* ──────────────────────── Helpers ──────────────────────── */
|
|
1910
|
+
function extractFilePaths(input, cwd) {
|
|
1911
|
+
const paths = [];
|
|
1912
|
+
const guard = new WorkspaceGuard(cwd);
|
|
1913
|
+
// Only match paths that look like real file references:
|
|
1914
|
+
// - Quoted paths with extensions
|
|
1915
|
+
// - Unquoted paths with a directory separator AND a code/doc extension
|
|
1916
|
+
const extensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.css', '.scss', '.json', '.md', '.yml', '.yaml', '.toml', '.env', '.sh', '.bash', '.txt', '.html', '.vue', '.svelte']);
|
|
1917
|
+
const extensionPattern = Array.from(extensions).join('|').replace(/\./g, '\\.');
|
|
1918
|
+
const patterns = [
|
|
1919
|
+
new RegExp(`'([^']+${extensionPattern})'`, 'g'),
|
|
1920
|
+
new RegExp(`"([^"]+${extensionPattern})"`, 'g'),
|
|
1921
|
+
new RegExp(`\\b([\\w.-]+\\/${extensionPattern})\\b`, 'g'),
|
|
1922
|
+
];
|
|
1923
|
+
for (const pattern of patterns) {
|
|
1924
|
+
let match;
|
|
1925
|
+
while ((match = pattern.exec(input)) !== null) {
|
|
1926
|
+
let filePath;
|
|
1927
|
+
try {
|
|
1928
|
+
filePath = guard.ensureFile(match[1]);
|
|
1929
|
+
}
|
|
1930
|
+
catch {
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
if (fs.existsSync(filePath) && !paths.includes(filePath)) {
|
|
1934
|
+
paths.push(filePath);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return paths;
|
|
1939
|
+
}
|
|
1940
|
+
function printStats(stats) {
|
|
1941
|
+
const totalTokens = stats.totalPromptTokens + stats.totalCompletionTokens;
|
|
1942
|
+
const avgDuration = stats.totalTasks > 0
|
|
1943
|
+
? (stats.totalDurationMs / stats.totalTasks / 1000).toFixed(1)
|
|
1944
|
+
: '0';
|
|
1945
|
+
// Rough cost estimation: $3/M input + $15/M output tokens (average across providers)
|
|
1946
|
+
const estimatedCost = (stats.totalPromptTokens / 1_000_000) * 3 +
|
|
1947
|
+
(stats.totalCompletionTokens / 1_000_000) * 15;
|
|
1948
|
+
console.log('');
|
|
1949
|
+
console.log(`${c.cyan}${c.bold}📊 Session Statistics${c.reset}`);
|
|
1950
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
1951
|
+
console.log(` Tasks completed: ${c.bold}${stats.totalTasks}${c.reset}`);
|
|
1952
|
+
console.log(` Tool calls: ${c.bold}${stats.totalToolCalls}${c.reset}`);
|
|
1953
|
+
console.log(` Input tokens: ${c.bold}${stats.totalPromptTokens.toLocaleString()}${c.reset}`);
|
|
1954
|
+
console.log(` Output tokens: ${c.bold}${stats.totalCompletionTokens.toLocaleString()}${c.reset}`);
|
|
1955
|
+
console.log(` Total tokens: ${c.bold}${totalTokens.toLocaleString()}${c.reset}`);
|
|
1956
|
+
console.log(` Avg task duration: ${c.bold}${avgDuration}s${c.reset}`);
|
|
1957
|
+
console.log(` Cost savings: ${c.green}${c.bold}~$${estimatedCost.toFixed(2)} saved${c.reset} ${c.dim}(free models!)${c.reset}`);
|
|
1958
|
+
console.log('');
|
|
1959
|
+
}
|
|
1960
|
+
//# sourceMappingURL=prompt.js.map
|