codemaxxing 0.4.0 → 0.4.1
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/dist/config.d.ts +3 -0
- package/dist/config.js +13 -0
- package/dist/index.js +227 -11
- package/dist/utils/models.d.ts +7 -0
- package/dist/utils/models.js +64 -0
- package/dist/utils/ollama.d.ts +20 -0
- package/dist/utils/ollama.js +95 -3
- package/package.json +1 -1
- package/src/config.ts +15 -0
- package/src/index.tsx +267 -9
- package/src/utils/models.ts +80 -0
- package/src/utils/ollama.ts +99 -4
package/dist/config.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface CodemaxxingConfig {
|
|
|
17
17
|
contextCompressionThreshold?: number;
|
|
18
18
|
architectModel?: string;
|
|
19
19
|
autoLint?: boolean;
|
|
20
|
+
stopOllamaOnExit?: boolean;
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
export interface CLIArgs {
|
|
@@ -30,6 +31,8 @@ export interface CLIArgs {
|
|
|
30
31
|
*/
|
|
31
32
|
export declare function parseCLIArgs(): CLIArgs;
|
|
32
33
|
export declare function loadConfig(): CodemaxxingConfig;
|
|
34
|
+
/** Save config to disk (merges with existing) */
|
|
35
|
+
export declare function saveConfig(updates: Partial<CodemaxxingConfig>): void;
|
|
33
36
|
/**
|
|
34
37
|
* Apply CLI overrides to a provider config
|
|
35
38
|
*/
|
package/dist/config.js
CHANGED
|
@@ -102,6 +102,19 @@ export function loadConfig() {
|
|
|
102
102
|
return DEFAULT_CONFIG;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
/** Save config to disk (merges with existing) */
|
|
106
|
+
export function saveConfig(updates) {
|
|
107
|
+
const current = loadConfig();
|
|
108
|
+
const merged = {
|
|
109
|
+
...current,
|
|
110
|
+
...updates,
|
|
111
|
+
defaults: { ...current.defaults, ...(updates.defaults ?? {}) },
|
|
112
|
+
};
|
|
113
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
114
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
117
|
+
}
|
|
105
118
|
/**
|
|
106
119
|
* Apply CLI overrides to a provider config
|
|
107
120
|
*/
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from "ink";
|
|
|
5
5
|
import { EventEmitter } from "events";
|
|
6
6
|
import TextInput from "ink-text-input";
|
|
7
7
|
import { CodingAgent } from "./agent.js";
|
|
8
|
-
import { loadConfig, detectLocalProvider, parseCLIArgs, applyOverrides, listModels } from "./config.js";
|
|
8
|
+
import { loadConfig, saveConfig, detectLocalProvider, parseCLIArgs, applyOverrides, listModels } from "./config.js";
|
|
9
9
|
import { listSessions, getSession, loadMessages, deleteSession } from "./utils/sessions.js";
|
|
10
10
|
import { execSync } from "child_process";
|
|
11
11
|
import { isGitRepo, getBranch, getStatus, getDiff, undoLastCommit } from "./utils/git.js";
|
|
@@ -14,8 +14,8 @@ import { PROVIDERS, getCredentials, openRouterOAuth, anthropicSetupToken, import
|
|
|
14
14
|
import { listInstalledSkills, installSkill, removeSkill, getRegistrySkills, searchRegistry, createSkillScaffold, getActiveSkills, getActiveSkillCount } from "./utils/skills.js";
|
|
15
15
|
import { listServers, addServer, removeServer, getConnectedServers } from "./utils/mcp.js";
|
|
16
16
|
import { detectHardware, formatBytes } from "./utils/hardware.js";
|
|
17
|
-
import {
|
|
18
|
-
import { isOllamaInstalled, isOllamaRunning, getOllamaInstallCommand, startOllama, pullModel } from "./utils/ollama.js";
|
|
17
|
+
import { getRecommendationsWithLlmfit, getFitIcon, isLlmfitAvailable } from "./utils/models.js";
|
|
18
|
+
import { isOllamaInstalled, isOllamaRunning, getOllamaInstallCommand, startOllama, stopOllama, pullModel, listInstalledModelsDetailed, deleteModel, getGPUMemoryUsage } from "./utils/ollama.js";
|
|
19
19
|
const VERSION = "0.1.9";
|
|
20
20
|
// ── Helpers ──
|
|
21
21
|
function formatTimeAgo(date) {
|
|
@@ -67,6 +67,12 @@ const SLASH_COMMANDS = [
|
|
|
67
67
|
{ cmd: "/mcp add", desc: "add MCP server" },
|
|
68
68
|
{ cmd: "/mcp remove", desc: "remove MCP server" },
|
|
69
69
|
{ cmd: "/mcp reconnect", desc: "reconnect MCP servers" },
|
|
70
|
+
{ cmd: "/ollama", desc: "Ollama status & models" },
|
|
71
|
+
{ cmd: "/ollama list", desc: "list installed models" },
|
|
72
|
+
{ cmd: "/ollama start", desc: "start Ollama server" },
|
|
73
|
+
{ cmd: "/ollama stop", desc: "stop Ollama server" },
|
|
74
|
+
{ cmd: "/ollama pull", desc: "download a model" },
|
|
75
|
+
{ cmd: "/ollama delete", desc: "delete a model" },
|
|
70
76
|
{ cmd: "/quit", desc: "exit" },
|
|
71
77
|
];
|
|
72
78
|
const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
|
|
@@ -140,6 +146,10 @@ function App() {
|
|
|
140
146
|
const [skillsPickerIndex, setSkillsPickerIndex] = useState(0);
|
|
141
147
|
const [sessionDisabledSkills, setSessionDisabledSkills] = useState(new Set());
|
|
142
148
|
const [approval, setApproval] = useState(null);
|
|
149
|
+
// ── Ollama Management State ──
|
|
150
|
+
const [ollamaDeleteConfirm, setOllamaDeleteConfirm] = useState(null);
|
|
151
|
+
const [ollamaPulling, setOllamaPulling] = useState(null);
|
|
152
|
+
const [ollamaExitPrompt, setOllamaExitPrompt] = useState(false);
|
|
143
153
|
const [wizardScreen, setWizardScreen] = useState(null);
|
|
144
154
|
const [wizardIndex, setWizardIndex] = useState(0);
|
|
145
155
|
const [wizardHardware, setWizardHardware] = useState(null);
|
|
@@ -329,7 +339,8 @@ function App() {
|
|
|
329
339
|
// Commands that need args (like /commit, /model) — fill input instead of executing
|
|
330
340
|
if (selected.cmd === "/commit" || selected.cmd === "/model" || selected.cmd === "/session delete" ||
|
|
331
341
|
selected.cmd === "/skills install" || selected.cmd === "/skills remove" || selected.cmd === "/skills search" ||
|
|
332
|
-
selected.cmd === "/skills on" || selected.cmd === "/skills off" || selected.cmd === "/architect"
|
|
342
|
+
selected.cmd === "/skills on" || selected.cmd === "/skills off" || selected.cmd === "/architect" ||
|
|
343
|
+
selected.cmd === "/ollama pull" || selected.cmd === "/ollama delete") {
|
|
333
344
|
setInput(selected.cmd + " ");
|
|
334
345
|
setCmdIndex(0);
|
|
335
346
|
setInputKey((k) => k + 1);
|
|
@@ -355,7 +366,22 @@ function App() {
|
|
|
355
366
|
return;
|
|
356
367
|
addMsg("user", trimmed);
|
|
357
368
|
if (trimmed === "/quit" || trimmed === "/exit") {
|
|
358
|
-
|
|
369
|
+
// Check if Ollama is running and offer to stop it
|
|
370
|
+
const ollamaUp = await isOllamaRunning();
|
|
371
|
+
if (ollamaUp) {
|
|
372
|
+
const config = loadConfig();
|
|
373
|
+
if (config.defaults.stopOllamaOnExit) {
|
|
374
|
+
addMsg("info", "Stopping Ollama...");
|
|
375
|
+
await stopOllama();
|
|
376
|
+
exit();
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
setOllamaExitPrompt(true);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
exit();
|
|
384
|
+
}
|
|
359
385
|
return;
|
|
360
386
|
}
|
|
361
387
|
if (trimmed === "/login" || trimmed === "/auth") {
|
|
@@ -398,6 +424,12 @@ function App() {
|
|
|
398
424
|
" /mcp add — add MCP server to global config",
|
|
399
425
|
" /mcp remove — remove MCP server",
|
|
400
426
|
" /mcp reconnect — reconnect all MCP servers",
|
|
427
|
+
" /ollama — Ollama status, models & GPU usage",
|
|
428
|
+
" /ollama list — list installed models with sizes",
|
|
429
|
+
" /ollama start — start Ollama server",
|
|
430
|
+
" /ollama stop — stop Ollama server (frees GPU RAM)",
|
|
431
|
+
" /ollama pull <model> — download a model",
|
|
432
|
+
" /ollama delete <model> — delete a model from disk",
|
|
401
433
|
" /quit — exit",
|
|
402
434
|
].join("\n"));
|
|
403
435
|
return;
|
|
@@ -565,6 +597,142 @@ function App() {
|
|
|
565
597
|
addMsg("info", "🔍 Auto-lint OFF");
|
|
566
598
|
return;
|
|
567
599
|
}
|
|
600
|
+
// ── Ollama commands (work without agent) ──
|
|
601
|
+
if (trimmed === "/ollama" || trimmed === "/ollama status") {
|
|
602
|
+
const running = await isOllamaRunning();
|
|
603
|
+
const lines = [`Ollama: ${running ? "running" : "stopped"}`];
|
|
604
|
+
if (running) {
|
|
605
|
+
const models = await listInstalledModelsDetailed();
|
|
606
|
+
if (models.length > 0) {
|
|
607
|
+
lines.push(`Installed models (${models.length}):`);
|
|
608
|
+
for (const m of models) {
|
|
609
|
+
const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1);
|
|
610
|
+
lines.push(` ${m.name} (${sizeGB} GB)`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
lines.push("No models installed.");
|
|
615
|
+
}
|
|
616
|
+
const gpuMem = getGPUMemoryUsage();
|
|
617
|
+
if (gpuMem)
|
|
618
|
+
lines.push(`GPU: ${gpuMem}`);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
lines.push("Start with: /ollama start");
|
|
622
|
+
}
|
|
623
|
+
addMsg("info", lines.join("\n"));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (trimmed === "/ollama list") {
|
|
627
|
+
const running = await isOllamaRunning();
|
|
628
|
+
if (!running) {
|
|
629
|
+
addMsg("info", "Ollama is not running. Start with /ollama start");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const models = await listInstalledModelsDetailed();
|
|
633
|
+
if (models.length === 0) {
|
|
634
|
+
addMsg("info", "No models installed. Pull one with /ollama pull <model>");
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
const lines = models.map((m) => {
|
|
638
|
+
const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1);
|
|
639
|
+
return ` ${m.name} (${sizeGB} GB)`;
|
|
640
|
+
});
|
|
641
|
+
addMsg("info", `Installed models:\n${lines.join("\n")}`);
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (trimmed === "/ollama start") {
|
|
646
|
+
const running = await isOllamaRunning();
|
|
647
|
+
if (running) {
|
|
648
|
+
addMsg("info", "Ollama is already running.");
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (!isOllamaInstalled()) {
|
|
652
|
+
addMsg("error", `Ollama is not installed. Install with: ${getOllamaInstallCommand(process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux")}`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
startOllama();
|
|
656
|
+
addMsg("info", "Starting Ollama server...");
|
|
657
|
+
// Wait for it to come up
|
|
658
|
+
for (let i = 0; i < 10; i++) {
|
|
659
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
660
|
+
if (await isOllamaRunning()) {
|
|
661
|
+
addMsg("info", "Ollama is running.");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
addMsg("error", "Ollama did not start in time. Try running 'ollama serve' manually.");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (trimmed === "/ollama stop") {
|
|
669
|
+
const running = await isOllamaRunning();
|
|
670
|
+
if (!running) {
|
|
671
|
+
addMsg("info", "Ollama is not running.");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
addMsg("info", "Stopping Ollama...");
|
|
675
|
+
const result = await stopOllama();
|
|
676
|
+
addMsg(result.ok ? "info" : "error", result.ok ? `\u2705 ${result.message}` : `\u274C ${result.message}`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (trimmed.startsWith("/ollama pull ")) {
|
|
680
|
+
const modelId = trimmed.replace("/ollama pull ", "").trim();
|
|
681
|
+
if (!modelId) {
|
|
682
|
+
addMsg("info", "Usage: /ollama pull <model>\n Example: /ollama pull qwen2.5-coder:7b");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!isOllamaInstalled()) {
|
|
686
|
+
addMsg("error", "Ollama is not installed.");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// Ensure ollama is running
|
|
690
|
+
let running = await isOllamaRunning();
|
|
691
|
+
if (!running) {
|
|
692
|
+
startOllama();
|
|
693
|
+
addMsg("info", "Starting Ollama server...");
|
|
694
|
+
for (let i = 0; i < 10; i++) {
|
|
695
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
696
|
+
if (await isOllamaRunning()) {
|
|
697
|
+
running = true;
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (!running) {
|
|
702
|
+
addMsg("error", "Could not start Ollama. Run 'ollama serve' manually.");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
setOllamaPulling({ model: modelId, progress: { status: "starting", percent: 0 } });
|
|
707
|
+
try {
|
|
708
|
+
await pullModel(modelId, (p) => {
|
|
709
|
+
setOllamaPulling({ model: modelId, progress: p });
|
|
710
|
+
});
|
|
711
|
+
setOllamaPulling(null);
|
|
712
|
+
addMsg("info", `\u2705 Downloaded ${modelId}`);
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
setOllamaPulling(null);
|
|
716
|
+
addMsg("error", `Failed to pull ${modelId}: ${err.message}`);
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (trimmed.startsWith("/ollama delete ")) {
|
|
721
|
+
const modelId = trimmed.replace("/ollama delete ", "").trim();
|
|
722
|
+
if (!modelId) {
|
|
723
|
+
addMsg("info", "Usage: /ollama delete <model>");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
// Look up size for confirmation
|
|
727
|
+
const models = await listInstalledModelsDetailed();
|
|
728
|
+
const found = models.find((m) => m.name === modelId || m.name.startsWith(modelId));
|
|
729
|
+
if (!found) {
|
|
730
|
+
addMsg("error", `Model "${modelId}" not found. Use /ollama list to see installed models.`);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
setOllamaDeleteConfirm({ model: found.name, size: found.size });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
568
736
|
// ── MCP commands (partially work without agent) ──
|
|
569
737
|
if (trimmed === "/mcp" || trimmed === "/mcp list") {
|
|
570
738
|
const servers = listServers(process.cwd());
|
|
@@ -1125,6 +1293,47 @@ function App() {
|
|
|
1125
1293
|
}
|
|
1126
1294
|
return;
|
|
1127
1295
|
}
|
|
1296
|
+
// ── Ollama delete confirmation ──
|
|
1297
|
+
if (ollamaDeleteConfirm) {
|
|
1298
|
+
if (inputChar === "y" || inputChar === "Y") {
|
|
1299
|
+
const model = ollamaDeleteConfirm.model;
|
|
1300
|
+
setOllamaDeleteConfirm(null);
|
|
1301
|
+
const result = deleteModel(model);
|
|
1302
|
+
addMsg(result.ok ? "info" : "error", result.ok ? `\u2705 ${result.message}` : `\u274C ${result.message}`);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (inputChar === "n" || inputChar === "N" || key.escape) {
|
|
1306
|
+
setOllamaDeleteConfirm(null);
|
|
1307
|
+
addMsg("info", "Delete cancelled.");
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
// ── Ollama exit prompt ──
|
|
1313
|
+
if (ollamaExitPrompt) {
|
|
1314
|
+
if (inputChar === "y" || inputChar === "Y") {
|
|
1315
|
+
setOllamaExitPrompt(false);
|
|
1316
|
+
stopOllama().then(() => exit());
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (inputChar === "n" || inputChar === "N") {
|
|
1320
|
+
setOllamaExitPrompt(false);
|
|
1321
|
+
exit();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (inputChar === "a" || inputChar === "A") {
|
|
1325
|
+
setOllamaExitPrompt(false);
|
|
1326
|
+
saveConfig({ defaults: { ...loadConfig().defaults, stopOllamaOnExit: true } });
|
|
1327
|
+
addMsg("info", "Saved preference: always stop Ollama on exit.");
|
|
1328
|
+
stopOllama().then(() => exit());
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (key.escape) {
|
|
1332
|
+
setOllamaExitPrompt(false);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1128
1337
|
// ── Setup Wizard Navigation ──
|
|
1129
1338
|
if (wizardScreen) {
|
|
1130
1339
|
if (wizardScreen === "connection") {
|
|
@@ -1144,11 +1353,11 @@ function App() {
|
|
|
1144
1353
|
if (key.return) {
|
|
1145
1354
|
const selected = items[wizardIndex];
|
|
1146
1355
|
if (selected === "local") {
|
|
1147
|
-
// Scan hardware and show model picker
|
|
1356
|
+
// Scan hardware and show model picker (use llmfit if available)
|
|
1148
1357
|
const hw = detectHardware();
|
|
1149
1358
|
setWizardHardware(hw);
|
|
1150
|
-
const recs =
|
|
1151
|
-
setWizardModels(recs);
|
|
1359
|
+
const { models: recs } = getRecommendationsWithLlmfit(hw);
|
|
1360
|
+
setWizardModels(recs.filter(m => m.fit !== "skip"));
|
|
1152
1361
|
setWizardScreen("models");
|
|
1153
1362
|
setWizardIndex(0);
|
|
1154
1363
|
}
|
|
@@ -1494,7 +1703,14 @@ function App() {
|
|
|
1494
1703
|
}
|
|
1495
1704
|
if (key.ctrl && inputChar === "c") {
|
|
1496
1705
|
if (ctrlCPressed) {
|
|
1497
|
-
|
|
1706
|
+
// Force quit on second Ctrl+C — don't block
|
|
1707
|
+
const config = loadConfig();
|
|
1708
|
+
if (config.defaults.stopOllamaOnExit) {
|
|
1709
|
+
stopOllama().finally(() => exit());
|
|
1710
|
+
}
|
|
1711
|
+
else {
|
|
1712
|
+
exit();
|
|
1713
|
+
}
|
|
1498
1714
|
}
|
|
1499
1715
|
else {
|
|
1500
1716
|
setCtrlCPressed(true);
|
|
@@ -1572,12 +1788,12 @@ function App() {
|
|
|
1572
1788
|
})(), skillsPicker === "remove" && (() => {
|
|
1573
1789
|
const installed = listInstalledSkills();
|
|
1574
1790
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.error, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.error, children: "Remove a skill:" }), installed.map((s, i) => (_jsxs(Text, { children: [i === skillsPickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsxs(Text, { color: i === skillsPickerIndex ? theme.colors.suggestion : theme.colors.muted, children: [s.name, " \u2014 ", s.description] })] }, s.name))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter remove · Esc back" })] }));
|
|
1575
|
-
})(), themePicker && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Choose a theme:" }), listThemes().map((key, i) => (_jsxs(Text, { children: [i === themePickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === themePickerIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: key }), _jsxs(Text, { color: theme.colors.muted, children: [" — ", THEMES[key].description] }), key === theme.name.toLowerCase() ? _jsx(Text, { color: theme.colors.muted, children: " (current)" }) : null] }, key))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter select · Esc cancel" })] })), sessionPicker && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Resume a session:" }), sessionPicker.map((s, i) => (_jsxs(Text, { children: [i === sessionPickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === sessionPickerIndex ? theme.colors.suggestion : theme.colors.muted, children: s.display })] }, s.id))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter select · Esc cancel" })] })), deleteSessionPicker && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.error, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.error, children: "Delete a session:" }), deleteSessionPicker.map((s, i) => (_jsxs(Text, { children: [i === deleteSessionPickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === deleteSessionPickerIndex ? theme.colors.suggestion : theme.colors.muted, children: s.display })] }, s.id))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter select · Esc cancel" })] })), deleteSessionConfirm && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.warning, paddingX: 1, marginBottom: 0, children: [_jsxs(Text, { bold: true, color: theme.colors.warning, children: ["Delete session ", deleteSessionConfirm.id, "?"] }), _jsxs(Text, { color: theme.colors.muted, children: [" ", deleteSessionConfirm.display] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.error, bold: true, children: " [y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: theme.colors.success, bold: true, children: "[n]" }), _jsx(Text, { children: "o" })] })] })), wizardScreen === "connection" && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "No LLM detected. How do you want to connect?" }), _jsx(Text, { children: "" }), [
|
|
1791
|
+
})(), themePicker && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Choose a theme:" }), listThemes().map((key, i) => (_jsxs(Text, { children: [i === themePickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === themePickerIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: key }), _jsxs(Text, { color: theme.colors.muted, children: [" — ", THEMES[key].description] }), key === theme.name.toLowerCase() ? _jsx(Text, { color: theme.colors.muted, children: " (current)" }) : null] }, key))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter select · Esc cancel" })] })), sessionPicker && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Resume a session:" }), sessionPicker.map((s, i) => (_jsxs(Text, { children: [i === sessionPickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === sessionPickerIndex ? theme.colors.suggestion : theme.colors.muted, children: s.display })] }, s.id))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter select · Esc cancel" })] })), deleteSessionPicker && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.error, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.error, children: "Delete a session:" }), deleteSessionPicker.map((s, i) => (_jsxs(Text, { children: [i === deleteSessionPickerIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === deleteSessionPickerIndex ? theme.colors.suggestion : theme.colors.muted, children: s.display })] }, s.id))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Enter select · Esc cancel" })] })), deleteSessionConfirm && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.warning, paddingX: 1, marginBottom: 0, children: [_jsxs(Text, { bold: true, color: theme.colors.warning, children: ["Delete session ", deleteSessionConfirm.id, "?"] }), _jsxs(Text, { color: theme.colors.muted, children: [" ", deleteSessionConfirm.display] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.error, bold: true, children: " [y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: theme.colors.success, bold: true, children: "[n]" }), _jsx(Text, { children: "o" })] })] })), ollamaDeleteConfirm && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.warning, paddingX: 1, marginBottom: 0, children: [_jsxs(Text, { bold: true, color: theme.colors.warning, children: ["Delete ", ollamaDeleteConfirm.model, " (", (ollamaDeleteConfirm.size / (1024 * 1024 * 1024)).toFixed(1), " GB)?"] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.error, bold: true, children: " [y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: theme.colors.success, bold: true, children: "[n]" }), _jsx(Text, { children: "o" })] })] })), ollamaPulling && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsxs(Text, { bold: true, color: theme.colors.secondary, children: [" Downloading ", ollamaPulling.model, "..."] }), ollamaPulling.progress.status === "downloading" || ollamaPulling.progress.percent > 0 ? (_jsxs(Text, { children: [" ", _jsxs(Text, { color: theme.colors.primary, children: ["\u2588".repeat(Math.floor(ollamaPulling.progress.percent / 5)), "\u2591".repeat(20 - Math.floor(ollamaPulling.progress.percent / 5))] }), " ", _jsxs(Text, { bold: true, children: [ollamaPulling.progress.percent, "%"] }), ollamaPulling.progress.completed != null && ollamaPulling.progress.total != null ? (_jsxs(Text, { color: theme.colors.muted, children: [" \u00B7 ", formatBytes(ollamaPulling.progress.completed), " / ", formatBytes(ollamaPulling.progress.total)] })) : null] })) : (_jsxs(Text, { color: theme.colors.muted, children: [" ", ollamaPulling.progress.status, "..."] }))] })), ollamaExitPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.warning, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.warning, children: "Ollama is still running." }), _jsx(Text, { color: theme.colors.muted, children: "Stop it to free GPU memory?" }), _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.success, bold: true, children: " [y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: theme.colors.error, bold: true, children: "[n]" }), _jsx(Text, { children: "o " }), _jsx(Text, { color: theme.colors.primary, bold: true, children: "[a]" }), _jsx(Text, { children: "lways" })] })] })), wizardScreen === "connection" && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "No LLM detected. How do you want to connect?" }), _jsx(Text, { children: "" }), [
|
|
1576
1792
|
{ key: "local", icon: "\uD83D\uDDA5\uFE0F", label: "Set up a local model", desc: "free, runs on your machine" },
|
|
1577
1793
|
{ key: "openrouter", icon: "\uD83C\uDF10", label: "OpenRouter", desc: "200+ cloud models, browser login" },
|
|
1578
1794
|
{ key: "apikey", icon: "\uD83D\uDD11", label: "Enter API key manually", desc: "" },
|
|
1579
1795
|
{ key: "existing", icon: "\u2699\uFE0F", label: "I already have a server running", desc: "" },
|
|
1580
|
-
].map((item, i) => (_jsxs(Text, { children: [i === wizardIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: " \u25B8 " }) : _jsx(Text, { children: " " }), _jsxs(Text, { color: i === wizardIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: [item.icon, " ", item.label] }), item.desc ? _jsxs(Text, { color: theme.colors.muted, children: [" (", item.desc, ")"] }) : null] }, item.key))), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter to select" })] })), wizardScreen === "models" && wizardHardware && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Your hardware:" }), _jsxs(Text, { color: theme.colors.muted, children: [" CPU: ", wizardHardware.cpu.name, " (", wizardHardware.cpu.cores, " cores)"] }), _jsxs(Text, { color: theme.colors.muted, children: [" RAM: ", formatBytes(wizardHardware.ram)] }), wizardHardware.gpu ? (_jsxs(Text, { color: theme.colors.muted, children: [" GPU: ", wizardHardware.gpu.name, wizardHardware.gpu.vram > 0 ? ` (${formatBytes(wizardHardware.gpu.vram)})` : ""] })) : (_jsx(Text, { color: theme.colors.muted, children: " GPU: none detected" })), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Recommended models:" }), _jsx(Text, { children: "" }), wizardModels.map((m, i) => (_jsxs(Text, { children: [i === wizardIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: " \u25B8 " }) : _jsx(Text, { children: " " }), _jsxs(Text, { children: [getFitIcon(m.fit), " "] }), _jsx(Text, { color: i === wizardIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: m.name }), _jsxs(Text, { color: theme.colors.muted, children: [" ~", m.size, " GB \u00B7 ", m.quality === "best" ? "Best" : m.quality === "great" ? "Great" : "Good", " quality \u00B7 ", m.speed] })] }, m.ollamaId))), wizardModels.length === 0 && (_jsx(Text, { color: theme.colors.error, children: " No suitable models found for your hardware." })), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter to install \u00B7 Esc back" })] })), wizardScreen === "install-ollama" && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.warning, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.warning, children: "Ollama is required for local models." }), _jsx(Text, { children: "" }), _jsxs(Text, { color: theme.colors.primary, children: [" Install with: ", _jsx(Text, { bold: true, children: getOllamaInstallCommand(wizardHardware?.os ?? "linux") })] }), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " Run the command above, then press Enter to continue..." }), _jsx(Text, { dimColor: true, children: " Esc to go back" })] })), wizardScreen === "pulling" && wizardSelectedModel && (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: wizardPullError ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.colors.error, bold: true, children: [" \u274C Error: ", wizardPullError] }), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " Press Enter to retry \u00B7 Esc to go back" })] })) : wizardPullProgress ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, color: theme.colors.secondary, children: [" Downloading ", wizardSelectedModel.name, "..."] }), wizardPullProgress.status === "downloading" || wizardPullProgress.percent > 0 ? (_jsx(_Fragment, { children: _jsxs(Text, { children: [" ", _jsxs(Text, { color: theme.colors.primary, children: ["\u2588".repeat(Math.floor(wizardPullProgress.percent / 5)), "\u2591".repeat(20 - Math.floor(wizardPullProgress.percent / 5))] }), " ", _jsxs(Text, { bold: true, children: [wizardPullProgress.percent, "%"] }), wizardPullProgress.completed != null && wizardPullProgress.total != null ? (_jsxs(Text, { color: theme.colors.muted, children: [" \u00B7 ", formatBytes(wizardPullProgress.completed), " / ", formatBytes(wizardPullProgress.total)] })) : null] }) })) : (_jsxs(Text, { color: theme.colors.muted, children: [" ", wizardPullProgress.status, "..."] }))] })) : null })), showSuggestions && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.muted, paddingX: 1, marginBottom: 0, children: [cmdMatches.slice(0, 6).map((c, i) => (_jsxs(Text, { children: [i === cmdIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === cmdIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: c.cmd }), _jsxs(Text, { color: theme.colors.muted, children: [" — ", c.desc] })] }, i))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Tab select" })] })), _jsxs(Box, { borderStyle: "single", borderColor: approval ? theme.colors.warning : theme.colors.border, paddingX: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: "> " }), approval ? (_jsx(Text, { color: theme.colors.warning, children: "waiting for approval..." })) : ready && !loading ? (_jsxs(Box, { children: [pastedChunks.map((p) => (_jsxs(Text, { color: theme.colors.muted, children: ["[Pasted text #", p.id, " +", p.lines, " lines]"] }, p.id))), _jsx(TextInput, { value: input, onChange: (v) => { setInput(v); setCmdIndex(0); }, onSubmit: handleSubmit }, inputKey)] })) : (_jsx(Text, { dimColor: true, children: loading ? "waiting for response..." : "initializing..." }))] }), agent && (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { dimColor: true, children: ["💬 ", agent.getContextLength(), " messages · ~", (() => {
|
|
1796
|
+
].map((item, i) => (_jsxs(Text, { children: [i === wizardIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: " \u25B8 " }) : _jsx(Text, { children: " " }), _jsxs(Text, { color: i === wizardIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: [item.icon, " ", item.label] }), item.desc ? _jsxs(Text, { color: theme.colors.muted, children: [" (", item.desc, ")"] }) : null] }, item.key))), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter to select" })] })), wizardScreen === "models" && wizardHardware && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Your hardware:" }), _jsxs(Text, { color: theme.colors.muted, children: [" CPU: ", wizardHardware.cpu.name, " (", wizardHardware.cpu.cores, " cores)"] }), _jsxs(Text, { color: theme.colors.muted, children: [" RAM: ", formatBytes(wizardHardware.ram)] }), wizardHardware.gpu ? (_jsxs(Text, { color: theme.colors.muted, children: [" GPU: ", wizardHardware.gpu.name, wizardHardware.gpu.vram > 0 ? ` (${formatBytes(wizardHardware.gpu.vram)})` : ""] })) : (_jsx(Text, { color: theme.colors.muted, children: " GPU: none detected" })), !isLlmfitAvailable() && (_jsx(Text, { dimColor: true, children: " Tip: Install llmfit for smarter recommendations: brew install llmfit" })), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Recommended models:" }), _jsx(Text, { children: "" }), wizardModels.map((m, i) => (_jsxs(Text, { children: [i === wizardIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: " \u25B8 " }) : _jsx(Text, { children: " " }), _jsxs(Text, { children: [getFitIcon(m.fit), " "] }), _jsx(Text, { color: i === wizardIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: m.name }), _jsxs(Text, { color: theme.colors.muted, children: [" ~", m.size, " GB \u00B7 ", m.quality === "best" ? "Best" : m.quality === "great" ? "Great" : "Good", " quality \u00B7 ", m.speed] })] }, m.ollamaId))), wizardModels.length === 0 && (_jsx(Text, { color: theme.colors.error, children: " No suitable models found for your hardware." })), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter to install \u00B7 Esc back" })] })), wizardScreen === "install-ollama" && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.warning, paddingX: 1, marginBottom: 0, children: [_jsx(Text, { bold: true, color: theme.colors.warning, children: "Ollama is required for local models." }), _jsx(Text, { children: "" }), _jsxs(Text, { color: theme.colors.primary, children: [" Install with: ", _jsx(Text, { bold: true, children: getOllamaInstallCommand(wizardHardware?.os ?? "linux") })] }), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " Run the command above, then press Enter to continue..." }), _jsx(Text, { dimColor: true, children: " Esc to go back" })] })), wizardScreen === "pulling" && wizardSelectedModel && (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.border, paddingX: 1, marginBottom: 0, children: wizardPullError ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.colors.error, bold: true, children: [" \u274C Error: ", wizardPullError] }), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: " Press Enter to retry \u00B7 Esc to go back" })] })) : wizardPullProgress ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, color: theme.colors.secondary, children: [" Downloading ", wizardSelectedModel.name, "..."] }), wizardPullProgress.status === "downloading" || wizardPullProgress.percent > 0 ? (_jsx(_Fragment, { children: _jsxs(Text, { children: [" ", _jsxs(Text, { color: theme.colors.primary, children: ["\u2588".repeat(Math.floor(wizardPullProgress.percent / 5)), "\u2591".repeat(20 - Math.floor(wizardPullProgress.percent / 5))] }), " ", _jsxs(Text, { bold: true, children: [wizardPullProgress.percent, "%"] }), wizardPullProgress.completed != null && wizardPullProgress.total != null ? (_jsxs(Text, { color: theme.colors.muted, children: [" \u00B7 ", formatBytes(wizardPullProgress.completed), " / ", formatBytes(wizardPullProgress.total)] })) : null] }) })) : (_jsxs(Text, { color: theme.colors.muted, children: [" ", wizardPullProgress.status, "..."] }))] })) : null })), showSuggestions && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.muted, paddingX: 1, marginBottom: 0, children: [cmdMatches.slice(0, 6).map((c, i) => (_jsxs(Text, { children: [i === cmdIndex ? _jsx(Text, { color: theme.colors.suggestion, bold: true, children: "▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: i === cmdIndex ? theme.colors.suggestion : theme.colors.primary, bold: true, children: c.cmd }), _jsxs(Text, { color: theme.colors.muted, children: [" — ", c.desc] })] }, i))), _jsx(Text, { dimColor: true, children: " ↑↓ navigate · Tab select" })] })), _jsxs(Box, { borderStyle: "single", borderColor: approval ? theme.colors.warning : theme.colors.border, paddingX: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: "> " }), approval ? (_jsx(Text, { color: theme.colors.warning, children: "waiting for approval..." })) : ready && !loading ? (_jsxs(Box, { children: [pastedChunks.map((p) => (_jsxs(Text, { color: theme.colors.muted, children: ["[Pasted text #", p.id, " +", p.lines, " lines]"] }, p.id))), _jsx(TextInput, { value: input, onChange: (v) => { setInput(v); setCmdIndex(0); }, onSubmit: handleSubmit }, inputKey)] })) : (_jsx(Text, { dimColor: true, children: loading ? "waiting for response..." : "initializing..." }))] }), agent && (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { dimColor: true, children: ["💬 ", agent.getContextLength(), " messages · ~", (() => {
|
|
1581
1797
|
const tokens = agent.estimateTokens();
|
|
1582
1798
|
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
1583
1799
|
})(), " tokens", (() => {
|
package/dist/utils/models.d.ts
CHANGED
|
@@ -15,3 +15,10 @@ export interface ScoredModel extends RecommendedModel {
|
|
|
15
15
|
}
|
|
16
16
|
export declare function getRecommendations(hardware: HardwareInfo): ScoredModel[];
|
|
17
17
|
export declare function getFitIcon(fit: ModelFit): string;
|
|
18
|
+
/** Check if llmfit binary is available */
|
|
19
|
+
export declare function isLlmfitAvailable(): boolean;
|
|
20
|
+
/** Get recommendations using llmfit if available, otherwise fall back to hardcoded list */
|
|
21
|
+
export declare function getRecommendationsWithLlmfit(hardware: HardwareInfo): {
|
|
22
|
+
models: ScoredModel[];
|
|
23
|
+
usedLlmfit: boolean;
|
|
24
|
+
};
|
package/dist/utils/models.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
1
2
|
const MODELS = [
|
|
2
3
|
{
|
|
3
4
|
name: "Qwen 2.5 Coder 3B",
|
|
@@ -111,3 +112,66 @@ export function getFitIcon(fit) {
|
|
|
111
112
|
case "skip": return "\u274C"; // ❌
|
|
112
113
|
}
|
|
113
114
|
}
|
|
115
|
+
/** Check if llmfit binary is available */
|
|
116
|
+
export function isLlmfitAvailable() {
|
|
117
|
+
try {
|
|
118
|
+
const cmd = process.platform === "win32" ? "where llmfit" : "which llmfit";
|
|
119
|
+
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function mapLlmfitFit(fit) {
|
|
127
|
+
switch (fit) {
|
|
128
|
+
case "perfect": return "perfect";
|
|
129
|
+
case "good": return "good";
|
|
130
|
+
case "marginal": return "tight";
|
|
131
|
+
default: return "skip";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function mapLlmfitQuality(params_b) {
|
|
135
|
+
if (params_b >= 14)
|
|
136
|
+
return "best";
|
|
137
|
+
if (params_b >= 7)
|
|
138
|
+
return "great";
|
|
139
|
+
return "good";
|
|
140
|
+
}
|
|
141
|
+
/** Get recommendations using llmfit if available, otherwise fall back to hardcoded list */
|
|
142
|
+
export function getRecommendationsWithLlmfit(hardware) {
|
|
143
|
+
if (!isLlmfitAvailable()) {
|
|
144
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const raw = execSync("llmfit recommend --use-case coding --format json --limit 10", {
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
timeout: 15000,
|
|
150
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
151
|
+
});
|
|
152
|
+
const llmfitModels = JSON.parse(raw.trim());
|
|
153
|
+
if (!Array.isArray(llmfitModels) || llmfitModels.length === 0) {
|
|
154
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
155
|
+
}
|
|
156
|
+
const scored = llmfitModels
|
|
157
|
+
.filter((m) => m.provider === "ollama")
|
|
158
|
+
.map((m) => ({
|
|
159
|
+
name: m.name.split(":")[0]?.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()) + ` ${m.params_b}B`,
|
|
160
|
+
ollamaId: m.name,
|
|
161
|
+
size: Math.ceil(m.vram_gb),
|
|
162
|
+
ramRequired: Math.ceil(m.ram_gb),
|
|
163
|
+
vramOptimal: Math.ceil(m.vram_gb),
|
|
164
|
+
description: `${m.quant} · ~${m.estimated_tps.toFixed(0)} tok/s`,
|
|
165
|
+
speed: `~${m.estimated_tps.toFixed(0)} tok/s`,
|
|
166
|
+
quality: mapLlmfitQuality(m.params_b),
|
|
167
|
+
fit: mapLlmfitFit(m.fit),
|
|
168
|
+
}));
|
|
169
|
+
if (scored.length === 0) {
|
|
170
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
171
|
+
}
|
|
172
|
+
return { models: scored, usedLlmfit: true };
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/utils/ollama.d.ts
CHANGED
|
@@ -18,5 +18,25 @@ export interface PullProgress {
|
|
|
18
18
|
* Returns a promise that resolves when complete.
|
|
19
19
|
*/
|
|
20
20
|
export declare function pullModel(modelId: string, onProgress?: (progress: PullProgress) => void): Promise<void>;
|
|
21
|
+
export interface OllamaModelInfo {
|
|
22
|
+
name: string;
|
|
23
|
+
size: number;
|
|
24
|
+
modified_at: string;
|
|
25
|
+
digest: string;
|
|
26
|
+
}
|
|
27
|
+
/** List models installed in Ollama with detailed info */
|
|
28
|
+
export declare function listInstalledModelsDetailed(): Promise<OllamaModelInfo[]>;
|
|
21
29
|
/** List models installed in Ollama */
|
|
22
30
|
export declare function listInstalledModels(): Promise<string[]>;
|
|
31
|
+
/** Stop all loaded models (frees VRAM) and kill the Ollama server process */
|
|
32
|
+
export declare function stopOllama(): Promise<{
|
|
33
|
+
ok: boolean;
|
|
34
|
+
message: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Delete a model from disk */
|
|
37
|
+
export declare function deleteModel(modelId: string): {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
/** Get GPU memory usage info (best-effort) */
|
|
42
|
+
export declare function getGPUMemoryUsage(): string | null;
|
package/dist/utils/ollama.js
CHANGED
|
@@ -104,8 +104,8 @@ export function pullModel(modelId, onProgress) {
|
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
|
-
/** List models installed in Ollama */
|
|
108
|
-
export async function
|
|
107
|
+
/** List models installed in Ollama with detailed info */
|
|
108
|
+
export async function listInstalledModelsDetailed() {
|
|
109
109
|
try {
|
|
110
110
|
const controller = new AbortController();
|
|
111
111
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
@@ -113,9 +113,101 @@ export async function listInstalledModels() {
|
|
|
113
113
|
clearTimeout(timeout);
|
|
114
114
|
if (res.ok) {
|
|
115
115
|
const data = (await res.json());
|
|
116
|
-
return (data.models ?? []).map((m) =>
|
|
116
|
+
return (data.models ?? []).map((m) => ({
|
|
117
|
+
name: m.name,
|
|
118
|
+
size: m.size,
|
|
119
|
+
modified_at: m.modified_at,
|
|
120
|
+
digest: m.digest,
|
|
121
|
+
}));
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
catch { /* not running */ }
|
|
120
125
|
return [];
|
|
121
126
|
}
|
|
127
|
+
/** List models installed in Ollama */
|
|
128
|
+
export async function listInstalledModels() {
|
|
129
|
+
const models = await listInstalledModelsDetailed();
|
|
130
|
+
return models.map((m) => m.name);
|
|
131
|
+
}
|
|
132
|
+
/** Stop all loaded models (frees VRAM) and kill the Ollama server process */
|
|
133
|
+
export async function stopOllama() {
|
|
134
|
+
try {
|
|
135
|
+
// First unload all models from memory
|
|
136
|
+
try {
|
|
137
|
+
execSync("ollama stop", { stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
|
|
138
|
+
}
|
|
139
|
+
catch { /* may fail if no models loaded */ }
|
|
140
|
+
// Kill the server process
|
|
141
|
+
if (process.platform === "win32") {
|
|
142
|
+
execSync("taskkill /f /im ollama.exe", { stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
|
|
143
|
+
}
|
|
144
|
+
else if (process.platform === "darwin") {
|
|
145
|
+
// Try launchctl first (Ollama app), then pkill
|
|
146
|
+
try {
|
|
147
|
+
execSync("launchctl stop com.ollama.ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
try {
|
|
151
|
+
execSync("pkill ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
152
|
+
}
|
|
153
|
+
catch { /* already stopped */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Linux
|
|
158
|
+
try {
|
|
159
|
+
execSync("systemctl stop ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
try {
|
|
163
|
+
execSync("pkill ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
164
|
+
}
|
|
165
|
+
catch { /* already stopped */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Verify it stopped
|
|
169
|
+
await new Promise(r => setTimeout(r, 500));
|
|
170
|
+
const stillRunning = await isOllamaRunning();
|
|
171
|
+
if (stillRunning) {
|
|
172
|
+
return { ok: false, message: "Ollama is still running. Try killing it manually." };
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, message: "Ollama stopped." };
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return { ok: false, message: `Failed to stop Ollama: ${err.message}` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Delete a model from disk */
|
|
181
|
+
export function deleteModel(modelId) {
|
|
182
|
+
try {
|
|
183
|
+
execSync(`ollama rm ${modelId}`, { stdio: ["pipe", "pipe", "pipe"], timeout: 30000 });
|
|
184
|
+
return { ok: true, message: `Deleted ${modelId}` };
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return { ok: false, message: `Failed to delete ${modelId}: ${err.stderr?.toString().trim() || err.message}` };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Get GPU memory usage info (best-effort) */
|
|
191
|
+
export function getGPUMemoryUsage() {
|
|
192
|
+
try {
|
|
193
|
+
if (process.platform === "darwin") {
|
|
194
|
+
// Apple Silicon — check memory pressure
|
|
195
|
+
const raw = execSync("memory_pressure", { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
196
|
+
const match = raw.match(/System-wide memory free percentage:\s*(\d+)%/);
|
|
197
|
+
if (match) {
|
|
198
|
+
return `${100 - parseInt(match[1])}% system memory in use`;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
// NVIDIA GPU
|
|
203
|
+
const raw = execSync("nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader,nounits", {
|
|
204
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
205
|
+
});
|
|
206
|
+
const parts = raw.trim().split(",").map(s => s.trim());
|
|
207
|
+
if (parts.length === 2) {
|
|
208
|
+
return `${parts[0]} MiB / ${parts[1]} MiB GPU memory`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch { /* no GPU info available */ }
|
|
212
|
+
return null;
|
|
213
|
+
}
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface CodemaxxingConfig {
|
|
|
24
24
|
contextCompressionThreshold?: number;
|
|
25
25
|
architectModel?: string;
|
|
26
26
|
autoLint?: boolean;
|
|
27
|
+
stopOllamaOnExit?: boolean;
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -138,6 +139,20 @@ export function loadConfig(): CodemaxxingConfig {
|
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/** Save config to disk (merges with existing) */
|
|
143
|
+
export function saveConfig(updates: Partial<CodemaxxingConfig>): void {
|
|
144
|
+
const current = loadConfig();
|
|
145
|
+
const merged = {
|
|
146
|
+
...current,
|
|
147
|
+
...updates,
|
|
148
|
+
defaults: { ...current.defaults, ...(updates.defaults ?? {}) },
|
|
149
|
+
};
|
|
150
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
151
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
154
|
+
}
|
|
155
|
+
|
|
141
156
|
/**
|
|
142
157
|
* Apply CLI overrides to a provider config
|
|
143
158
|
*/
|
package/src/index.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from "ink";
|
|
|
5
5
|
import { EventEmitter } from "events";
|
|
6
6
|
import TextInput from "ink-text-input";
|
|
7
7
|
import { CodingAgent } from "./agent.js";
|
|
8
|
-
import { loadConfig, detectLocalProvider, parseCLIArgs, applyOverrides, listModels } from "./config.js";
|
|
8
|
+
import { loadConfig, saveConfig, detectLocalProvider, parseCLIArgs, applyOverrides, listModels } from "./config.js";
|
|
9
9
|
import { listSessions, getSession, loadMessages, deleteSession } from "./utils/sessions.js";
|
|
10
10
|
import { execSync } from "child_process";
|
|
11
11
|
import { isGitRepo, getBranch, getStatus, getDiff, undoLastCommit } from "./utils/git.js";
|
|
@@ -14,8 +14,8 @@ import { PROVIDERS, getCredentials, openRouterOAuth, anthropicSetupToken, import
|
|
|
14
14
|
import { listInstalledSkills, installSkill, removeSkill, getRegistrySkills, searchRegistry, createSkillScaffold, getActiveSkills, getActiveSkillCount } from "./utils/skills.js";
|
|
15
15
|
import { listServers, addServer, removeServer, getAllMCPTools, getConnectedServers } from "./utils/mcp.js";
|
|
16
16
|
import { detectHardware, formatBytes, type HardwareInfo } from "./utils/hardware.js";
|
|
17
|
-
import { getRecommendations, getFitIcon, type ScoredModel } from "./utils/models.js";
|
|
18
|
-
import { isOllamaInstalled, isOllamaRunning, getOllamaInstallCommand, startOllama, pullModel, type PullProgress } from "./utils/ollama.js";
|
|
17
|
+
import { getRecommendations, getRecommendationsWithLlmfit, getFitIcon, isLlmfitAvailable, type ScoredModel } from "./utils/models.js";
|
|
18
|
+
import { isOllamaInstalled, isOllamaRunning, getOllamaInstallCommand, startOllama, stopOllama, pullModel, listInstalledModelsDetailed, deleteModel, getGPUMemoryUsage, type PullProgress } from "./utils/ollama.js";
|
|
19
19
|
|
|
20
20
|
const VERSION = "0.1.9";
|
|
21
21
|
|
|
@@ -67,6 +67,12 @@ const SLASH_COMMANDS = [
|
|
|
67
67
|
{ cmd: "/mcp add", desc: "add MCP server" },
|
|
68
68
|
{ cmd: "/mcp remove", desc: "remove MCP server" },
|
|
69
69
|
{ cmd: "/mcp reconnect", desc: "reconnect MCP servers" },
|
|
70
|
+
{ cmd: "/ollama", desc: "Ollama status & models" },
|
|
71
|
+
{ cmd: "/ollama list", desc: "list installed models" },
|
|
72
|
+
{ cmd: "/ollama start", desc: "start Ollama server" },
|
|
73
|
+
{ cmd: "/ollama stop", desc: "stop Ollama server" },
|
|
74
|
+
{ cmd: "/ollama pull", desc: "download a model" },
|
|
75
|
+
{ cmd: "/ollama delete", desc: "delete a model" },
|
|
70
76
|
{ cmd: "/quit", desc: "exit" },
|
|
71
77
|
];
|
|
72
78
|
|
|
@@ -175,6 +181,11 @@ function App() {
|
|
|
175
181
|
resolve: (decision: "yes" | "no" | "always") => void;
|
|
176
182
|
} | null>(null);
|
|
177
183
|
|
|
184
|
+
// ── Ollama Management State ──
|
|
185
|
+
const [ollamaDeleteConfirm, setOllamaDeleteConfirm] = useState<{ model: string; size: number } | null>(null);
|
|
186
|
+
const [ollamaPulling, setOllamaPulling] = useState<{ model: string; progress: PullProgress } | null>(null);
|
|
187
|
+
const [ollamaExitPrompt, setOllamaExitPrompt] = useState(false);
|
|
188
|
+
|
|
178
189
|
// ── Setup Wizard State ──
|
|
179
190
|
type WizardScreen = "connection" | "models" | "install-ollama" | "pulling" | null;
|
|
180
191
|
const [wizardScreen, setWizardScreen] = useState<WizardScreen>(null);
|
|
@@ -383,7 +394,8 @@ function App() {
|
|
|
383
394
|
// Commands that need args (like /commit, /model) — fill input instead of executing
|
|
384
395
|
if (selected.cmd === "/commit" || selected.cmd === "/model" || selected.cmd === "/session delete" ||
|
|
385
396
|
selected.cmd === "/skills install" || selected.cmd === "/skills remove" || selected.cmd === "/skills search" ||
|
|
386
|
-
selected.cmd === "/skills on" || selected.cmd === "/skills off" || selected.cmd === "/architect"
|
|
397
|
+
selected.cmd === "/skills on" || selected.cmd === "/skills off" || selected.cmd === "/architect" ||
|
|
398
|
+
selected.cmd === "/ollama pull" || selected.cmd === "/ollama delete") {
|
|
387
399
|
setInput(selected.cmd + " ");
|
|
388
400
|
setCmdIndex(0);
|
|
389
401
|
setInputKey((k) => k + 1);
|
|
@@ -411,7 +423,20 @@ function App() {
|
|
|
411
423
|
addMsg("user", trimmed);
|
|
412
424
|
|
|
413
425
|
if (trimmed === "/quit" || trimmed === "/exit") {
|
|
414
|
-
|
|
426
|
+
// Check if Ollama is running and offer to stop it
|
|
427
|
+
const ollamaUp = await isOllamaRunning();
|
|
428
|
+
if (ollamaUp) {
|
|
429
|
+
const config = loadConfig();
|
|
430
|
+
if (config.defaults.stopOllamaOnExit) {
|
|
431
|
+
addMsg("info", "Stopping Ollama...");
|
|
432
|
+
await stopOllama();
|
|
433
|
+
exit();
|
|
434
|
+
} else {
|
|
435
|
+
setOllamaExitPrompt(true);
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
exit();
|
|
439
|
+
}
|
|
415
440
|
return;
|
|
416
441
|
}
|
|
417
442
|
if (trimmed === "/login" || trimmed === "/auth") {
|
|
@@ -454,6 +479,12 @@ function App() {
|
|
|
454
479
|
" /mcp add — add MCP server to global config",
|
|
455
480
|
" /mcp remove — remove MCP server",
|
|
456
481
|
" /mcp reconnect — reconnect all MCP servers",
|
|
482
|
+
" /ollama — Ollama status, models & GPU usage",
|
|
483
|
+
" /ollama list — list installed models with sizes",
|
|
484
|
+
" /ollama start — start Ollama server",
|
|
485
|
+
" /ollama stop — stop Ollama server (frees GPU RAM)",
|
|
486
|
+
" /ollama pull <model> — download a model",
|
|
487
|
+
" /ollama delete <model> — delete a model from disk",
|
|
457
488
|
" /quit — exit",
|
|
458
489
|
].join("\n"));
|
|
459
490
|
return;
|
|
@@ -614,6 +645,135 @@ function App() {
|
|
|
614
645
|
return;
|
|
615
646
|
}
|
|
616
647
|
|
|
648
|
+
// ── Ollama commands (work without agent) ──
|
|
649
|
+
if (trimmed === "/ollama" || trimmed === "/ollama status") {
|
|
650
|
+
const running = await isOllamaRunning();
|
|
651
|
+
const lines: string[] = [`Ollama: ${running ? "running" : "stopped"}`];
|
|
652
|
+
if (running) {
|
|
653
|
+
const models = await listInstalledModelsDetailed();
|
|
654
|
+
if (models.length > 0) {
|
|
655
|
+
lines.push(`Installed models (${models.length}):`);
|
|
656
|
+
for (const m of models) {
|
|
657
|
+
const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1);
|
|
658
|
+
lines.push(` ${m.name} (${sizeGB} GB)`);
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
lines.push("No models installed.");
|
|
662
|
+
}
|
|
663
|
+
const gpuMem = getGPUMemoryUsage();
|
|
664
|
+
if (gpuMem) lines.push(`GPU: ${gpuMem}`);
|
|
665
|
+
} else {
|
|
666
|
+
lines.push("Start with: /ollama start");
|
|
667
|
+
}
|
|
668
|
+
addMsg("info", lines.join("\n"));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (trimmed === "/ollama list") {
|
|
672
|
+
const running = await isOllamaRunning();
|
|
673
|
+
if (!running) {
|
|
674
|
+
addMsg("info", "Ollama is not running. Start with /ollama start");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const models = await listInstalledModelsDetailed();
|
|
678
|
+
if (models.length === 0) {
|
|
679
|
+
addMsg("info", "No models installed. Pull one with /ollama pull <model>");
|
|
680
|
+
} else {
|
|
681
|
+
const lines = models.map((m) => {
|
|
682
|
+
const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1);
|
|
683
|
+
return ` ${m.name} (${sizeGB} GB)`;
|
|
684
|
+
});
|
|
685
|
+
addMsg("info", `Installed models:\n${lines.join("\n")}`);
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (trimmed === "/ollama start") {
|
|
690
|
+
const running = await isOllamaRunning();
|
|
691
|
+
if (running) {
|
|
692
|
+
addMsg("info", "Ollama is already running.");
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (!isOllamaInstalled()) {
|
|
696
|
+
addMsg("error", `Ollama is not installed. Install with: ${getOllamaInstallCommand(process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux")}`);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
startOllama();
|
|
700
|
+
addMsg("info", "Starting Ollama server...");
|
|
701
|
+
// Wait for it to come up
|
|
702
|
+
for (let i = 0; i < 10; i++) {
|
|
703
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
704
|
+
if (await isOllamaRunning()) {
|
|
705
|
+
addMsg("info", "Ollama is running.");
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
addMsg("error", "Ollama did not start in time. Try running 'ollama serve' manually.");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (trimmed === "/ollama stop") {
|
|
713
|
+
const running = await isOllamaRunning();
|
|
714
|
+
if (!running) {
|
|
715
|
+
addMsg("info", "Ollama is not running.");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
addMsg("info", "Stopping Ollama...");
|
|
719
|
+
const result = await stopOllama();
|
|
720
|
+
addMsg(result.ok ? "info" : "error", result.ok ? `\u2705 ${result.message}` : `\u274C ${result.message}`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (trimmed.startsWith("/ollama pull ")) {
|
|
724
|
+
const modelId = trimmed.replace("/ollama pull ", "").trim();
|
|
725
|
+
if (!modelId) {
|
|
726
|
+
addMsg("info", "Usage: /ollama pull <model>\n Example: /ollama pull qwen2.5-coder:7b");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (!isOllamaInstalled()) {
|
|
730
|
+
addMsg("error", "Ollama is not installed.");
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// Ensure ollama is running
|
|
734
|
+
let running = await isOllamaRunning();
|
|
735
|
+
if (!running) {
|
|
736
|
+
startOllama();
|
|
737
|
+
addMsg("info", "Starting Ollama server...");
|
|
738
|
+
for (let i = 0; i < 10; i++) {
|
|
739
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
740
|
+
if (await isOllamaRunning()) { running = true; break; }
|
|
741
|
+
}
|
|
742
|
+
if (!running) {
|
|
743
|
+
addMsg("error", "Could not start Ollama. Run 'ollama serve' manually.");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
setOllamaPulling({ model: modelId, progress: { status: "starting", percent: 0 } });
|
|
748
|
+
try {
|
|
749
|
+
await pullModel(modelId, (p) => {
|
|
750
|
+
setOllamaPulling({ model: modelId, progress: p });
|
|
751
|
+
});
|
|
752
|
+
setOllamaPulling(null);
|
|
753
|
+
addMsg("info", `\u2705 Downloaded ${modelId}`);
|
|
754
|
+
} catch (err: any) {
|
|
755
|
+
setOllamaPulling(null);
|
|
756
|
+
addMsg("error", `Failed to pull ${modelId}: ${err.message}`);
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (trimmed.startsWith("/ollama delete ")) {
|
|
761
|
+
const modelId = trimmed.replace("/ollama delete ", "").trim();
|
|
762
|
+
if (!modelId) {
|
|
763
|
+
addMsg("info", "Usage: /ollama delete <model>");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// Look up size for confirmation
|
|
767
|
+
const models = await listInstalledModelsDetailed();
|
|
768
|
+
const found = models.find((m) => m.name === modelId || m.name.startsWith(modelId));
|
|
769
|
+
if (!found) {
|
|
770
|
+
addMsg("error", `Model "${modelId}" not found. Use /ollama list to see installed models.`);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
setOllamaDeleteConfirm({ model: found.name, size: found.size });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
617
777
|
// ── MCP commands (partially work without agent) ──
|
|
618
778
|
if (trimmed === "/mcp" || trimmed === "/mcp list") {
|
|
619
779
|
const servers = listServers(process.cwd());
|
|
@@ -1155,6 +1315,49 @@ function App() {
|
|
|
1155
1315
|
return;
|
|
1156
1316
|
}
|
|
1157
1317
|
|
|
1318
|
+
// ── Ollama delete confirmation ──
|
|
1319
|
+
if (ollamaDeleteConfirm) {
|
|
1320
|
+
if (inputChar === "y" || inputChar === "Y") {
|
|
1321
|
+
const model = ollamaDeleteConfirm.model;
|
|
1322
|
+
setOllamaDeleteConfirm(null);
|
|
1323
|
+
const result = deleteModel(model);
|
|
1324
|
+
addMsg(result.ok ? "info" : "error", result.ok ? `\u2705 ${result.message}` : `\u274C ${result.message}`);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (inputChar === "n" || inputChar === "N" || key.escape) {
|
|
1328
|
+
setOllamaDeleteConfirm(null);
|
|
1329
|
+
addMsg("info", "Delete cancelled.");
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// ── Ollama exit prompt ──
|
|
1336
|
+
if (ollamaExitPrompt) {
|
|
1337
|
+
if (inputChar === "y" || inputChar === "Y") {
|
|
1338
|
+
setOllamaExitPrompt(false);
|
|
1339
|
+
stopOllama().then(() => exit());
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (inputChar === "n" || inputChar === "N") {
|
|
1343
|
+
setOllamaExitPrompt(false);
|
|
1344
|
+
exit();
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (inputChar === "a" || inputChar === "A") {
|
|
1348
|
+
setOllamaExitPrompt(false);
|
|
1349
|
+
saveConfig({ defaults: { ...loadConfig().defaults, stopOllamaOnExit: true } });
|
|
1350
|
+
addMsg("info", "Saved preference: always stop Ollama on exit.");
|
|
1351
|
+
stopOllama().then(() => exit());
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (key.escape) {
|
|
1355
|
+
setOllamaExitPrompt(false);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1158
1361
|
// ── Setup Wizard Navigation ──
|
|
1159
1362
|
if (wizardScreen) {
|
|
1160
1363
|
if (wizardScreen === "connection") {
|
|
@@ -1174,11 +1377,11 @@ function App() {
|
|
|
1174
1377
|
if (key.return) {
|
|
1175
1378
|
const selected = items[wizardIndex];
|
|
1176
1379
|
if (selected === "local") {
|
|
1177
|
-
// Scan hardware and show model picker
|
|
1380
|
+
// Scan hardware and show model picker (use llmfit if available)
|
|
1178
1381
|
const hw = detectHardware();
|
|
1179
1382
|
setWizardHardware(hw);
|
|
1180
|
-
const recs =
|
|
1181
|
-
setWizardModels(recs);
|
|
1383
|
+
const { models: recs } = getRecommendationsWithLlmfit(hw);
|
|
1384
|
+
setWizardModels(recs.filter(m => m.fit !== "skip"));
|
|
1182
1385
|
setWizardScreen("models");
|
|
1183
1386
|
setWizardIndex(0);
|
|
1184
1387
|
} else if (selected === "openrouter") {
|
|
@@ -1527,7 +1730,13 @@ function App() {
|
|
|
1527
1730
|
|
|
1528
1731
|
if (key.ctrl && inputChar === "c") {
|
|
1529
1732
|
if (ctrlCPressed) {
|
|
1530
|
-
|
|
1733
|
+
// Force quit on second Ctrl+C — don't block
|
|
1734
|
+
const config = loadConfig();
|
|
1735
|
+
if (config.defaults.stopOllamaOnExit) {
|
|
1736
|
+
stopOllama().finally(() => exit());
|
|
1737
|
+
} else {
|
|
1738
|
+
exit();
|
|
1739
|
+
}
|
|
1531
1740
|
} else {
|
|
1532
1741
|
setCtrlCPressed(true);
|
|
1533
1742
|
addMsg("info", "Press Ctrl+C again to exit.");
|
|
@@ -1832,6 +2041,52 @@ function App() {
|
|
|
1832
2041
|
</Box>
|
|
1833
2042
|
)}
|
|
1834
2043
|
|
|
2044
|
+
{/* ═══ OLLAMA DELETE CONFIRM ═══ */}
|
|
2045
|
+
{ollamaDeleteConfirm && (
|
|
2046
|
+
<Box flexDirection="column" borderStyle="single" borderColor={theme.colors.warning} paddingX={1} marginBottom={0}>
|
|
2047
|
+
<Text bold color={theme.colors.warning}>Delete {ollamaDeleteConfirm.model} ({(ollamaDeleteConfirm.size / (1024 * 1024 * 1024)).toFixed(1)} GB)?</Text>
|
|
2048
|
+
<Text>
|
|
2049
|
+
<Text color={theme.colors.error} bold> [y]</Text><Text>es </Text>
|
|
2050
|
+
<Text color={theme.colors.success} bold>[n]</Text><Text>o</Text>
|
|
2051
|
+
</Text>
|
|
2052
|
+
</Box>
|
|
2053
|
+
)}
|
|
2054
|
+
|
|
2055
|
+
{/* ═══ OLLAMA PULL PROGRESS ═══ */}
|
|
2056
|
+
{ollamaPulling && (
|
|
2057
|
+
<Box flexDirection="column" borderStyle="single" borderColor={theme.colors.border} paddingX={1} marginBottom={0}>
|
|
2058
|
+
<Text bold color={theme.colors.secondary}>{" Downloading "}{ollamaPulling.model}{"..."}</Text>
|
|
2059
|
+
{ollamaPulling.progress.status === "downloading" || ollamaPulling.progress.percent > 0 ? (
|
|
2060
|
+
<Text>
|
|
2061
|
+
{" "}
|
|
2062
|
+
<Text color={theme.colors.primary}>
|
|
2063
|
+
{"\u2588".repeat(Math.floor(ollamaPulling.progress.percent / 5))}
|
|
2064
|
+
{"\u2591".repeat(20 - Math.floor(ollamaPulling.progress.percent / 5))}
|
|
2065
|
+
</Text>
|
|
2066
|
+
{" "}<Text bold>{ollamaPulling.progress.percent}%</Text>
|
|
2067
|
+
{ollamaPulling.progress.completed != null && ollamaPulling.progress.total != null ? (
|
|
2068
|
+
<Text color={theme.colors.muted}>{" \u00B7 "}{formatBytes(ollamaPulling.progress.completed)}{" / "}{formatBytes(ollamaPulling.progress.total)}</Text>
|
|
2069
|
+
) : null}
|
|
2070
|
+
</Text>
|
|
2071
|
+
) : (
|
|
2072
|
+
<Text color={theme.colors.muted}>{" "}{ollamaPulling.progress.status}...</Text>
|
|
2073
|
+
)}
|
|
2074
|
+
</Box>
|
|
2075
|
+
)}
|
|
2076
|
+
|
|
2077
|
+
{/* ═══ OLLAMA EXIT PROMPT ═══ */}
|
|
2078
|
+
{ollamaExitPrompt && (
|
|
2079
|
+
<Box flexDirection="column" borderStyle="single" borderColor={theme.colors.warning} paddingX={1} marginBottom={0}>
|
|
2080
|
+
<Text bold color={theme.colors.warning}>Ollama is still running.</Text>
|
|
2081
|
+
<Text color={theme.colors.muted}>Stop it to free GPU memory?</Text>
|
|
2082
|
+
<Text>
|
|
2083
|
+
<Text color={theme.colors.success} bold> [y]</Text><Text>es </Text>
|
|
2084
|
+
<Text color={theme.colors.error} bold>[n]</Text><Text>o </Text>
|
|
2085
|
+
<Text color={theme.colors.primary} bold>[a]</Text><Text>lways</Text>
|
|
2086
|
+
</Text>
|
|
2087
|
+
</Box>
|
|
2088
|
+
)}
|
|
2089
|
+
|
|
1835
2090
|
{/* ═══ SETUP WIZARD ═══ */}
|
|
1836
2091
|
{wizardScreen === "connection" && (
|
|
1837
2092
|
<Box flexDirection="column" borderStyle="single" borderColor={theme.colors.border} paddingX={1} marginBottom={0}>
|
|
@@ -1864,6 +2119,9 @@ function App() {
|
|
|
1864
2119
|
) : (
|
|
1865
2120
|
<Text color={theme.colors.muted}>{" GPU: none detected"}</Text>
|
|
1866
2121
|
)}
|
|
2122
|
+
{!isLlmfitAvailable() && (
|
|
2123
|
+
<Text dimColor>{" Tip: Install llmfit for smarter recommendations: brew install llmfit"}</Text>
|
|
2124
|
+
)}
|
|
1867
2125
|
<Text>{""}</Text>
|
|
1868
2126
|
<Text bold color={theme.colors.secondary}>Recommended models:</Text>
|
|
1869
2127
|
<Text>{""}</Text>
|
package/src/utils/models.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
1
2
|
import type { HardwareInfo } from "./hardware.js";
|
|
2
3
|
|
|
3
4
|
export interface RecommendedModel {
|
|
@@ -135,3 +136,82 @@ export function getFitIcon(fit: ModelFit): string {
|
|
|
135
136
|
case "skip": return "\u274C"; // ❌
|
|
136
137
|
}
|
|
137
138
|
}
|
|
139
|
+
|
|
140
|
+
/** Check if llmfit binary is available */
|
|
141
|
+
export function isLlmfitAvailable(): boolean {
|
|
142
|
+
try {
|
|
143
|
+
const cmd = process.platform === "win32" ? "where llmfit" : "which llmfit";
|
|
144
|
+
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface LlmfitModel {
|
|
152
|
+
name: string;
|
|
153
|
+
provider: string;
|
|
154
|
+
params_b: number;
|
|
155
|
+
quant: string;
|
|
156
|
+
fit: string;
|
|
157
|
+
estimated_tps: number;
|
|
158
|
+
vram_gb: number;
|
|
159
|
+
ram_gb: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function mapLlmfitFit(fit: string): ModelFit {
|
|
163
|
+
switch (fit) {
|
|
164
|
+
case "perfect": return "perfect";
|
|
165
|
+
case "good": return "good";
|
|
166
|
+
case "marginal": return "tight";
|
|
167
|
+
default: return "skip";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function mapLlmfitQuality(params_b: number): "good" | "great" | "best" {
|
|
172
|
+
if (params_b >= 14) return "best";
|
|
173
|
+
if (params_b >= 7) return "great";
|
|
174
|
+
return "good";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Get recommendations using llmfit if available, otherwise fall back to hardcoded list */
|
|
178
|
+
export function getRecommendationsWithLlmfit(hardware: HardwareInfo): { models: ScoredModel[]; usedLlmfit: boolean } {
|
|
179
|
+
if (!isLlmfitAvailable()) {
|
|
180
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const raw = execSync("llmfit recommend --use-case coding --format json --limit 10", {
|
|
185
|
+
encoding: "utf-8",
|
|
186
|
+
timeout: 15000,
|
|
187
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const llmfitModels: LlmfitModel[] = JSON.parse(raw.trim());
|
|
191
|
+
if (!Array.isArray(llmfitModels) || llmfitModels.length === 0) {
|
|
192
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const scored: ScoredModel[] = llmfitModels
|
|
196
|
+
.filter((m) => m.provider === "ollama")
|
|
197
|
+
.map((m) => ({
|
|
198
|
+
name: m.name.split(":")[0]?.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()) + ` ${m.params_b}B`,
|
|
199
|
+
ollamaId: m.name,
|
|
200
|
+
size: Math.ceil(m.vram_gb),
|
|
201
|
+
ramRequired: Math.ceil(m.ram_gb),
|
|
202
|
+
vramOptimal: Math.ceil(m.vram_gb),
|
|
203
|
+
description: `${m.quant} · ~${m.estimated_tps.toFixed(0)} tok/s`,
|
|
204
|
+
speed: `~${m.estimated_tps.toFixed(0)} tok/s`,
|
|
205
|
+
quality: mapLlmfitQuality(m.params_b),
|
|
206
|
+
fit: mapLlmfitFit(m.fit),
|
|
207
|
+
}));
|
|
208
|
+
|
|
209
|
+
if (scored.length === 0) {
|
|
210
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { models: scored, usedLlmfit: true };
|
|
214
|
+
} catch {
|
|
215
|
+
return { models: getRecommendations(hardware), usedLlmfit: false };
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/utils/ollama.ts
CHANGED
|
@@ -121,17 +121,112 @@ export function pullModel(
|
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
export interface OllamaModelInfo {
|
|
125
|
+
name: string;
|
|
126
|
+
size: number; // bytes
|
|
127
|
+
modified_at: string;
|
|
128
|
+
digest: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** List models installed in Ollama with detailed info */
|
|
132
|
+
export async function listInstalledModelsDetailed(): Promise<OllamaModelInfo[]> {
|
|
126
133
|
try {
|
|
127
134
|
const controller = new AbortController();
|
|
128
135
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
129
136
|
const res = await fetch("http://localhost:11434/api/tags", { signal: controller.signal });
|
|
130
137
|
clearTimeout(timeout);
|
|
131
138
|
if (res.ok) {
|
|
132
|
-
const data = (await res.json()) as { models?: Array<{ name: string }> };
|
|
133
|
-
return (data.models ?? []).map((m) =>
|
|
139
|
+
const data = (await res.json()) as { models?: Array<{ name: string; size: number; modified_at: string; digest: string }> };
|
|
140
|
+
return (data.models ?? []).map((m) => ({
|
|
141
|
+
name: m.name,
|
|
142
|
+
size: m.size,
|
|
143
|
+
modified_at: m.modified_at,
|
|
144
|
+
digest: m.digest,
|
|
145
|
+
}));
|
|
134
146
|
}
|
|
135
147
|
} catch { /* not running */ }
|
|
136
148
|
return [];
|
|
137
149
|
}
|
|
150
|
+
|
|
151
|
+
/** List models installed in Ollama */
|
|
152
|
+
export async function listInstalledModels(): Promise<string[]> {
|
|
153
|
+
const models = await listInstalledModelsDetailed();
|
|
154
|
+
return models.map((m) => m.name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Stop all loaded models (frees VRAM) and kill the Ollama server process */
|
|
158
|
+
export async function stopOllama(): Promise<{ ok: boolean; message: string }> {
|
|
159
|
+
try {
|
|
160
|
+
// First unload all models from memory
|
|
161
|
+
try {
|
|
162
|
+
execSync("ollama stop", { stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
|
|
163
|
+
} catch { /* may fail if no models loaded */ }
|
|
164
|
+
|
|
165
|
+
// Kill the server process
|
|
166
|
+
if (process.platform === "win32") {
|
|
167
|
+
execSync("taskkill /f /im ollama.exe", { stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
|
|
168
|
+
} else if (process.platform === "darwin") {
|
|
169
|
+
// Try launchctl first (Ollama app), then pkill
|
|
170
|
+
try {
|
|
171
|
+
execSync("launchctl stop com.ollama.ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
172
|
+
} catch {
|
|
173
|
+
try {
|
|
174
|
+
execSync("pkill ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
175
|
+
} catch { /* already stopped */ }
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// Linux
|
|
179
|
+
try {
|
|
180
|
+
execSync("systemctl stop ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
181
|
+
} catch {
|
|
182
|
+
try {
|
|
183
|
+
execSync("pkill ollama", { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
184
|
+
} catch { /* already stopped */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Verify it stopped
|
|
189
|
+
await new Promise(r => setTimeout(r, 500));
|
|
190
|
+
const stillRunning = await isOllamaRunning();
|
|
191
|
+
if (stillRunning) {
|
|
192
|
+
return { ok: false, message: "Ollama is still running. Try killing it manually." };
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, message: "Ollama stopped." };
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
return { ok: false, message: `Failed to stop Ollama: ${err.message}` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Delete a model from disk */
|
|
201
|
+
export function deleteModel(modelId: string): { ok: boolean; message: string } {
|
|
202
|
+
try {
|
|
203
|
+
execSync(`ollama rm ${modelId}`, { stdio: ["pipe", "pipe", "pipe"], timeout: 30000 });
|
|
204
|
+
return { ok: true, message: `Deleted ${modelId}` };
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
return { ok: false, message: `Failed to delete ${modelId}: ${err.stderr?.toString().trim() || err.message}` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Get GPU memory usage info (best-effort) */
|
|
211
|
+
export function getGPUMemoryUsage(): string | null {
|
|
212
|
+
try {
|
|
213
|
+
if (process.platform === "darwin") {
|
|
214
|
+
// Apple Silicon — check memory pressure
|
|
215
|
+
const raw = execSync("memory_pressure", { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
216
|
+
const match = raw.match(/System-wide memory free percentage:\s*(\d+)%/);
|
|
217
|
+
if (match) {
|
|
218
|
+
return `${100 - parseInt(match[1])}% system memory in use`;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
// NVIDIA GPU
|
|
223
|
+
const raw = execSync("nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader,nounits", {
|
|
224
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
225
|
+
});
|
|
226
|
+
const parts = raw.trim().split(",").map(s => s.trim());
|
|
227
|
+
if (parts.length === 2) {
|
|
228
|
+
return `${parts[0]} MiB / ${parts[1]} MiB GPU memory`;
|
|
229
|
+
}
|
|
230
|
+
} catch { /* no GPU info available */ }
|
|
231
|
+
return null;
|
|
232
|
+
}
|