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 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 { getRecommendations, getFitIcon } from "./utils/models.js";
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
- exit();
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 = getRecommendations(hw).filter(m => m.fit !== "skip");
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
- exit();
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", (() => {
@@ -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
+ };
@@ -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
+ }
@@ -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;
@@ -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 listInstalledModels() {
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) => m.name);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemaxxing",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Open-source terminal coding agent. Connect any LLM. Max your code.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
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
- exit();
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 = getRecommendations(hw).filter(m => m.fit !== "skip");
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
- exit();
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>
@@ -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
+ }
@@ -121,17 +121,112 @@ export function pullModel(
121
121
  });
122
122
  }
123
123
 
124
- /** List models installed in Ollama */
125
- export async function listInstalledModels(): Promise<string[]> {
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) => m.name);
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
+ }