offgrid-ai 0.3.19 → 0.3.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { startServer, stopProfile, waitForReady, serverReady, isProfileRunning,
9
9
  import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from "./harness-pi.mjs";
10
10
  import { tailFriendly } from "./logs.mjs";
11
11
  import { estimateMemory } from "./estimate.mjs";
12
- import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
12
+ import { pc, formatBytes, renderRows, renderSection, renderCard, humanCapabilitySummary, statusText, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
13
13
  import { checkForUpdate, currentPackageVersion, detectInvocation, updateCommand, runUpdateCommand } from "./updates.mjs";
14
14
  import { removeInstallerPathEntries } from "./shell-path.mjs";
15
15
  import { configureLocalProfile } from "./profile-setup.mjs";
@@ -185,12 +185,12 @@ async function modelCommandCenter(catalog) {
185
185
 
186
186
  const prompt = createPrompt();
187
187
  try {
188
- const action = await prompt.choice("What do you want to do?", [
189
- { value: "inspect", label: "Inspect", hint: "View details" },
190
- { value: "setup", label: "Set up / sync", hint: "Create profile or sync Pi" },
191
- { value: "run", label: "Run", hint: "Start server and launch Pi" },
192
- { value: "benchmark", label: "Benchmark", hint: "Coming soon: local benchmark project" },
193
- { value: "remove", label: "Remove", hint: "Delete a saved profile" },
188
+ const action = await prompt.choice("What would you like to do?", [
189
+ { value: "run", label: "Start chatting", hint: "Start a local model and open Pi" },
190
+ { value: "setup", label: "Set up a downloaded model", hint: "One-time setup or Pi sync" },
191
+ { value: "inspect", label: "See model details", hint: "Show advanced paths, ports, and flags" },
192
+ { value: "benchmark", label: "Benchmark", hint: "Coming soon" },
193
+ { value: "remove", label: "Remove a saved setup", hint: "Delete a model setup from offgrid-ai" },
194
194
  ], "run");
195
195
  if (action === "benchmark") return await benchmarkFlow();
196
196
  const item = await chooseCatalogItem(prompt, items, action);
@@ -240,33 +240,38 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
240
240
  const index = items.findIndex(predicate);
241
241
  return index === -1 ? " " : String(index + 1).padStart(2, " ");
242
242
  };
243
+ const runningProfilesNow = [];
244
+ for (const profile of profiles) {
245
+ if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
246
+ }
247
+
248
+ console.log("\n" + renderCard("Your local AI workspace", renderRows([
249
+ ["Ready to chat", pc.green(`${profiles.length} saved setup${profiles.length === 1 ? "" : "s"}`)],
250
+ ["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} downloaded model${newModels.length === 1 ? "" : "s"}`) : pc.green("none")],
251
+ ["Running now", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
252
+ ["Next step", profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? "Set up a downloaded model" : "Download a model"],
253
+ ]), { formatBorder: pc.cyan }));
243
254
 
244
- console.log(pc.bold("\nSaved profiles"));
255
+ console.log("\n" + pc.bold("Ready to chat"));
245
256
  if (profiles.length === 0) {
246
- console.log(pc.dim(" None yet."));
257
+ console.log(renderCard("No saved setups yet", "Downloaded models will appear below. Set one up once, then it will be ready from here.", { formatBorder: pc.yellow }));
247
258
  } else {
248
259
  for (const profile of profiles) {
249
- const backend = backendFor(profile.backend);
250
- const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
251
- const running = await isProfileRunning(profile);
260
+ const running = runningProfilesNow.some((item) => item.id === profile.id);
252
261
  const piConfigured = await hasPiModel(profile);
253
- const c = colorMap[profile.backend] ?? pc.magenta;
254
262
  const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
255
- console.log(`${num}. ${running ? pc.green("●") : pc.dim("○")} ${pc.bold(profile.label)} ${c(`[${backend.label}]`)} · ${pc.cyan(profile.modelAlias)} ${piConfigured ? pc.green("· Pi synced") : pc.yellow("· Pi not synced")}`);
263
+ console.log(profileCatalogCard(num, profile, { running, piConfigured }));
256
264
  }
257
265
  }
258
266
 
259
- console.log("");
260
- console.log(pc.bold("Downloaded models not set up yet"));
267
+ console.log("\n" + pc.bold("Downloaded, needs one-time setup"));
261
268
  if (newModels.length === 0) {
262
- console.log(pc.dim(" None. Every downloaded GGUF has a profile."));
269
+ console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.green }));
263
270
  } else {
264
271
  for (const model of newModels.slice(0, 20)) {
265
272
  const caps = detectCapabilities(model.path, model.mmprojPath);
266
273
  const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
267
- console.log(`${num}. ${pc.cyan(model.label)} ${capabilityBadges(caps)} ${pc.dim(model.quant ?? "")}`);
268
- console.log(` alias: ${pc.cyan(model.aliasSuggestion)}`);
269
- console.log(` size: ${formatBytes(model.sizeBytes)}`);
274
+ console.log(downloadedModelCard(num, model, caps));
270
275
  }
271
276
  if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
272
277
  }
@@ -275,17 +280,45 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
275
280
  const backendItems = managedItems.filter((item) => item.backendId === backendId);
276
281
  if (backendItems.length === 0) continue;
277
282
  const be = BACKENDS[backendId];
278
- console.log("");
279
- console.log(pc.bold(`${be.label} models`));
283
+ console.log("\n" + pc.bold(`Available from ${be.label}`));
280
284
  for (const { model } of backendItems.slice(0, 10)) {
281
285
  const num = itemNumber((item) => item.type === "managed" && item.backendId === backendId && item.model.id === model.id);
282
- console.log(`${num}. ${pc.cyan(model.label)} ${pc.dim(model.quant ?? "")}`);
283
- console.log(` id: ${pc.cyan(model.id)}`);
286
+ console.log(managedModelCard(num, model, be));
284
287
  }
285
288
  if (backendItems.length > 10) console.log(pc.dim(` ... and ${backendItems.length - 10} more`));
286
289
  }
287
290
  }
288
291
 
292
+ function profileCatalogCard(num, profile, { running, piConfigured }) {
293
+ const backend = backendFor(profile.backend);
294
+ const caps = profile.capabilities ?? {};
295
+ const status = running ? statusText("running", "Running now") : statusText("ready", "Ready to chat");
296
+ return renderCard(`${num}. ${profile.label}`, renderRows([
297
+ ["Status", status],
298
+ ["Good for", humanCapabilitySummary(caps)],
299
+ ["Pi", piConfigured ? pc.green("Synced") : pc.yellow("Needs sync")],
300
+ ["Runs with", backend.label],
301
+ ]), { formatBorder: running ? pc.green : pc.cyan });
302
+ }
303
+
304
+ function downloadedModelCard(num, model, caps) {
305
+ return renderCard(`${num}. ${model.label}`, renderRows([
306
+ ["Status", statusText("warning", "Needs one-time setup")],
307
+ ["Good for", humanCapabilitySummary(caps)],
308
+ ["Size", formatBytes(model.sizeBytes)],
309
+ ["When selected", "offgrid-ai will recommend safe local settings"],
310
+ ]), { formatBorder: caps.mtp ? pc.blue : pc.yellow });
311
+ }
312
+
313
+ function managedModelCard(num, model, backend) {
314
+ return renderCard(`${num}. ${model.label}`, renderRows([
315
+ ["Status", statusText("info", "Available from another app")],
316
+ ["App", backend.label],
317
+ ["Model ID", pc.cyan(model.id)],
318
+ ...(model.quant ? [["Size/type", model.quant]] : []),
319
+ ]), { formatBorder: pc.magenta });
320
+ }
321
+
289
322
  function modelCatalogItems({ profiles, newModels, managedItems }) {
290
323
  return [
291
324
  ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}` })),
@@ -300,7 +333,7 @@ async function chooseCatalogItem(prompt, items, action) {
300
333
  return null;
301
334
  }
302
335
 
303
- const input = await prompt.text("Select a number", "");
336
+ const input = await prompt.text(action === "remove" ? "Which saved setup should be removed? Enter its number" : "Which model? Enter its number", "");
304
337
  if (!input) return null;
305
338
  const index = Number(input) - 1;
306
339
  if (!Number.isInteger(index) || index < 0 || index >= items.length) {
@@ -360,43 +393,53 @@ async function printProfileDetails(profile) {
360
393
  const backend = backendFor(profile.backend);
361
394
  const isManaged = backend.type === "managed-server";
362
395
  const piConfigured = await hasPiModel(profile);
363
- console.log("\n" + renderSection("Profile", renderRows([
364
- ["ID", pc.cyan(profile.id)],
365
- ["Label", pc.bold(profile.label)],
366
- ["Backend", backend.label],
396
+ const running = await isProfileRunning(profile);
397
+ console.log("\n" + renderSection("Model overview", renderRows([
398
+ ["Name", pc.bold(profile.label)],
399
+ ["Status", running ? pc.green("Running now") : pc.green("Ready to chat")],
400
+ ["Good for", humanCapabilitySummary(profile.capabilities ?? {})],
401
+ ["Pi", piConfigured ? pc.green("Synced") : pc.yellow("Needs sync")],
402
+ ["Server", pc.green(profile.baseUrl)],
403
+ ])));
404
+
405
+ console.log("\n" + renderSection("Advanced details", renderRows([
406
+ ["Setup ID", pc.cyan(profile.id)],
407
+ ["Runs with", backend.label],
408
+ ["Model alias", pc.cyan(profile.modelAlias)],
367
409
  ...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
368
- ["Endpoint", pc.green(profile.baseUrl)],
369
410
  ...(!isManaged ? [
370
- ["Model", profile.modelPath ?? "unknown"],
371
- ["MMProj", profile.mmprojPath ?? "none"],
372
- ["Memory", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
411
+ ["Local file", profile.modelPath ?? "unknown"],
412
+ ["Vision file", profile.mmprojPath ?? "none"],
413
+ ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
373
414
  ] : []),
374
- ["Alias", pc.cyan(profile.modelAlias)],
375
- ["Pi", piConfigured ? pc.green("configured") : pc.yellow("not synced")],
376
415
  ])));
377
416
 
378
417
  if (!isManaged && profile.commandArgv) {
379
- console.log("\n" + pc.bold("llama-server command"));
380
- console.log(pc.dim(buildPrettyCommand(profile)));
418
+ console.log("\n" + renderSection("Advanced command", pc.dim(buildPrettyCommand(profile))));
381
419
  }
382
420
  }
383
421
 
384
422
  function printGgufModelDetails(model) {
385
423
  const caps = detectCapabilities(model.path, model.mmprojPath);
386
- console.log("\n" + renderSection("GGUF model", renderRows([
387
- ["Label", pc.bold(model.label)],
424
+ console.log("\n" + renderSection("Downloaded model", renderRows([
425
+ ["Name", pc.bold(model.label)],
426
+ ["Status", pc.yellow("Needs one-time setup")],
427
+ ["Good for", humanCapabilitySummary(caps)],
428
+ ["Size", formatBytes(model.sizeBytes)],
429
+ ])));
430
+ console.log("\n" + renderSection("Advanced details", renderRows([
431
+ ["Local file", model.path],
432
+ ["Vision file", model.mmprojPath ?? "none"],
388
433
  ["Detected", capabilitySummary(caps)],
389
- ["Model", model.path],
390
- ["MMProj", model.mmprojPath ?? "none"],
391
434
  ["Quant", model.quant ?? "unknown"],
392
- ["Size", formatBytes(model.sizeBytes)],
393
435
  ])));
394
436
  }
395
437
 
396
438
  function printManagedModelDetails(model, backend) {
397
439
  console.log("\n" + renderSection(`${backend.label} model`, renderRows([
398
- ["Label", pc.bold(model.label)],
399
- ["ID", pc.cyan(model.id)],
440
+ ["Name", pc.bold(model.label)],
441
+ ["Status", pc.green("Available from another app")],
442
+ ["Model ID", pc.cyan(model.id)],
400
443
  ["Quant", model.quant ?? "unknown"],
401
444
  ["Family", model.family ?? "unknown"],
402
445
  ])));
@@ -414,16 +457,6 @@ function capabilitySummary(caps) {
414
457
  return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
415
458
  }
416
459
 
417
- function capabilityBadges(caps) {
418
- const badges = [];
419
- if (caps.mtp) badges.push(pc.blue("[MTP]"));
420
- if (caps.qat) badges.push(pc.green("[QAT]"));
421
-
422
- if (caps.thinking) badges.push(pc.magenta("[thinking]"));
423
- if (caps.vision) badges.push(pc.cyan("[vision]"));
424
- return badges.join(" ");
425
- }
426
-
427
460
  function createManagedProfile(model, backendId) {
428
461
  return normalizeProfile({
429
462
  id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
@@ -487,10 +520,10 @@ async function runProfile(profile, options = {}) {
487
520
  if (!isManaged && profile.modelPath && existsSync(profile.modelPath)) {
488
521
  try {
489
522
  const est = estimateMemory(profile.modelPath, profile.mmprojPath, null, profile.flags);
490
- console.log(renderSection("Memory", renderRows([
523
+ console.log(renderSection("Memory estimate", renderRows([
491
524
  ["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
492
- ["Model", formatBytes(est.modelBytes)],
493
- ["KV cache", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"],
525
+ ["Model file", formatBytes(est.modelBytes)],
526
+ ["Conversation memory", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"],
494
527
  ])));
495
528
  } catch { /* estimate failed, skip */ }
496
529
  }
@@ -541,9 +574,11 @@ async function removeProfileInteractive(id) {
541
574
  // ── Benchmark (stub) ────────────────────────────────────────────────────────
542
575
 
543
576
  async function benchmarkFlow() {
544
- console.log(pc.yellow("Benchmark support coming soon."));
545
- console.log(pc.dim("This will require the local-llm-visual-benchmark repo."));
546
- console.log(pc.dim("For now, start a model with offgrid-ai and run benchmarks manually."));
577
+ console.log("\n" + renderCard("Benchmark", renderRows([
578
+ ["Status", pc.yellow("Coming soon")],
579
+ ["What it will do", "Compare local models with repeatable prompts"],
580
+ ["For now", "Start a model with offgrid-ai, then run benchmarks manually"],
581
+ ]), { formatBorder: pc.yellow }));
547
582
  }
548
583
 
549
584
  // ── Status ──────────────────────────────────────────────────────────────────
@@ -562,21 +597,27 @@ async function statusCommand() {
562
597
  const running = statuses.filter((s) => s.status.running);
563
598
 
564
599
  if (running.length === 0) {
565
- console.log(pc.dim("No offgrid-ai servers are running."));
566
- if (profiles.length > 0) {
567
- console.log(pc.dim(`\n${profiles.length} profile(s) available. Run offgrid-ai to start one.`));
568
- }
600
+ console.log(renderCard("Status", renderRows([
601
+ ["Running now", pc.dim("none")],
602
+ ["Ready setups", profiles.length > 0 ? pc.green(String(profiles.length)) : pc.dim("none")],
603
+ ["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : "Run offgrid-ai to set up a model"],
604
+ ]), { formatBorder: profiles.length > 0 ? pc.cyan : pc.yellow }));
569
605
  return;
570
606
  }
571
607
 
572
- console.log(pc.bold(`${running.length} server${running.length === 1 ? "" : "s"} running`));
608
+ console.log(renderCard("Status", renderRows([
609
+ ["Running now", pc.green(`${running.length} model${running.length === 1 ? "" : "s"}`)],
610
+ ["Stop", "offgrid-ai stop"],
611
+ ]), { formatBorder: pc.green }));
573
612
  for (const { profile, status } of running) {
574
613
  const backend = backendFor(profile.backend);
575
- console.log(` ${pc.green("")} ${pc.bold(profile.label)} ${pc.dim(`[${backend.label}]`)}`);
576
- console.log(` id: ${pc.cyan(profile.id)} · pid: ${status.pid} · ${status.ready ? pc.green("ready") : pc.yellow("loading")}`);
577
- console.log(` ${profile.baseUrl}`);
614
+ console.log("\n" + renderCard(profile.label, renderRows([
615
+ ["Status", status.ready ? pc.green("Ready") : pc.yellow("Starting up")],
616
+ ["Runs with", backend.label],
617
+ ["Process", `pid ${status.pid}`],
618
+ ["Server", profile.baseUrl],
619
+ ]), { formatBorder: status.ready ? pc.green : pc.yellow }));
578
620
  }
579
- console.log(pc.dim("\nStop with: offgrid-ai stop"));
580
621
  }
581
622
 
582
623
  // ── Stop ────────────────────────────────────────────────────────────────────
@@ -1062,19 +1103,14 @@ async function printVersion() {
1062
1103
  }
1063
1104
 
1064
1105
  function printHelp() {
1065
- console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner
1066
-
1067
- Usage:
1068
- offgrid-ai Command center: inspect, set up, run, benchmark, or remove models
1069
- offgrid-ai status Show running local models
1070
- offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
1071
- offgrid-ai uninstall Remove offgrid-ai, clean up PATH, optionally keep profiles
1072
- offgrid-ai help Show this help
1073
- offgrid-ai version Show version
1074
-
1075
- Flags:
1076
- --verbose Show install output (brew, lms, ollama, etc.)
1077
-
1078
- First run? offgrid-ai walks you through installing everything you need.
1079
- After that, just run it — it finds your models, auto-configures, and launches Pi.`);
1106
+ console.log(renderCard("offgrid-ai", renderRows([
1107
+ ["What it is", "A privacy-first local AI runner"],
1108
+ ["Start", pc.bold("offgrid-ai")],
1109
+ ["Status", "offgrid-ai status"],
1110
+ ["Stop", "offgrid-ai stop"],
1111
+ ["Uninstall", "offgrid-ai uninstall"],
1112
+ ["Version", "offgrid-ai version"],
1113
+ ]), { formatBorder: pc.cyan }));
1114
+ console.log("\n" + renderCard("How it works", "Run offgrid-ai, choose a local model, and start chatting in Pi.\n\nFirst run walks you through missing tools. After that, offgrid-ai remembers your model setup.", { formatBorder: pc.magenta }));
1115
+ console.log("\n" + pc.dim("Tip: use --verbose only when you want detailed install output."));
1080
1116
  }
@@ -1,11 +1,11 @@
1
1
  import { estimateMemory } from "./estimate.mjs";
2
- import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
2
+ import { pc, formatBytes, renderRows, renderSection, humanCapabilitySummary } from "./ui.mjs";
3
3
 
4
4
  const CACHE_CHOICES = [
5
- { value: "bf16", label: "bf16", hint: "default: stable, good quality" },
6
- { value: "f16", label: "f16", hint: "stable fallback, similar memory to bf16" },
7
- { value: "q8_0", label: "q8_0", hint: "lower memory, usually safe" },
8
- { value: "q4_0", label: "q4_0", hint: "lowest memory, quality/speed tradeoff" },
5
+ { value: "bf16", label: "Balanced", hint: "recommended: stable, good quality" },
6
+ { value: "f16", label: "Compatible", hint: "stable fallback, similar memory use" },
7
+ { value: "q8_0", label: "Lower memory", hint: "usually safe, uses less memory" },
8
+ { value: "q4_0", label: "Smallest memory", hint: "maximum savings, quality/speed tradeoff" },
9
9
  ];
10
10
 
11
11
  const GENERAL_DEFAULTS = {
@@ -26,64 +26,49 @@ export async function configureLocalProfile(prompt, profile) {
26
26
  const caps = profile.capabilities ?? {};
27
27
 
28
28
  console.log("");
29
- console.log(renderSection("Model setup", renderRows([
29
+ console.log(renderSection("Let's set up this model", renderRows([
30
30
  ["Model", pc.bold(profile.label)],
31
- ["Detected", detectionSummary(caps)],
32
- ["Context", `${profile.flags.ctxSize.toLocaleString()} tokens`],
33
- ["KV cache", `${profile.flags.cacheTypeK}/${profile.flags.cacheTypeV}`],
34
- ["Sampling", samplingSummary(profile.flags)],
31
+ ["Good for", humanCapabilitySummary(caps)],
32
+ ["Conversation memory", `${profile.flags.ctxSize.toLocaleString()} tokens`],
33
+ ["Memory mode", `${profile.flags.cacheTypeK}/${profile.flags.cacheTypeV}`],
34
+ ["Response style", samplingSummary(profile.flags)],
35
35
  ])));
36
- console.log(pc.dim("Larger context windows use more memory. KV cache precision controls memory used by attention history."));
37
- console.log(pc.dim("Sampling defaults are shown for transparency; you can edit command.json later if needed.\n"));
36
+ console.log(pc.dim("You can accept the recommended settings. Bigger conversation memory uses more RAM.\n"));
38
37
 
39
38
  if (caps.mtp) {
40
- console.log(renderSection("Detected MTP", renderRows([
41
- ["Backend", "llama.cpp MTP"],
42
- ["Port", "8081"],
43
- ["Flags", "--spec-type draft-mtp --spec-draft-n-max 2"],
44
- ])));
45
- const useMtp = await prompt.yesNo("Use MTP speculative decoding flags?", true);
39
+ console.log(renderSection("Speed boost available", "This model supports fast draft mode. It can make responses feel faster while keeping everything local.\n\nAdvanced: uses llama.cpp MTP on port 8081."));
40
+ const useMtp = await prompt.yesNo("Use fast draft mode for this model?", true);
46
41
  configured = useMtp ? applyMtpDefaults(configured) : removeMtpDefaults(configured);
47
42
  }
48
43
 
49
44
  if (caps.qat) {
50
45
  console.log("");
51
- console.log(renderSection("Detected QAT model", renderRows([
52
- ["Meaning", "quantization-aware trained"],
53
- ["Runtime flags", "none required"],
54
- ])));
46
+ console.log(renderSection("Optimized model", "This model was trained to work well after compression. No extra runtime settings are needed."));
55
47
  }
56
48
 
57
49
  if (caps.thinking) {
58
50
  console.log("");
59
- console.log(renderSection("Detected thinking model", renderRows([
60
- ["Defaults", "thinking / loop-safe"],
61
- ["Flags", "--top-k 64 --presence-penalty 0 --repeat-penalty 1.1"],
62
- ["Template", "--chat-template-kwargs { enable_thinking: true }"],
63
- ])));
64
- const useThinking = await prompt.yesNo("Use these thinking/loop-safe defaults?", true);
51
+ console.log(renderSection("Reasoning mode", "This model can reason step by step. offgrid-ai can use safer defaults that reduce repetitive loops."));
52
+ const useThinking = await prompt.yesNo("Use reasoning-friendly defaults?", true);
65
53
  configured = useThinking ? applyThinkingDefaults(configured) : removeThinkingDefaults(configured);
66
54
  }
67
55
 
68
- const ctxSize = await prompt.number("Context window tokens", configured.flags.ctxSize, 1024, 1048576);
69
- const cacheTypeK = await prompt.choice("K cache precision", CACHE_CHOICES, configured.flags.cacheTypeK);
70
- const cacheTypeV = await prompt.choice("V cache precision", CACHE_CHOICES, configured.flags.cacheTypeV);
56
+ const ctxSize = await prompt.number("Conversation memory tokens", configured.flags.ctxSize, 1024, 1048576);
57
+ const cacheTypeK = await prompt.choice("Memory mode, part 1", CACHE_CHOICES, configured.flags.cacheTypeK);
58
+ const cacheTypeV = await prompt.choice("Memory mode, part 2", CACHE_CHOICES, configured.flags.cacheTypeV);
71
59
  configured = applyRuntimeFlagOverrides(configured, { ctxSize, cacheTypeK, cacheTypeV });
72
60
 
73
61
  console.log("");
74
- console.log(renderSection("Defaults", renderRows([
75
- ["Backend", configured.backend],
76
- ["Endpoint", configured.baseUrl],
77
- ["Temperature", configured.flags.temperature],
78
- ["Top-p", configured.flags.topP],
79
- ["Top-k", configured.flags.topK],
80
- ["Min-p", configured.flags.minP],
81
- ["Presence penalty", configured.flags.presencePenalty],
82
- ["Repeat penalty", configured.flags.repeatPenalty],
62
+ console.log(renderSection("Final setup", renderRows([
63
+ ["Runs with", configured.backend],
64
+ ["Local address", configured.baseUrl],
65
+ ["Creativity", configured.flags.temperature],
66
+ ["Focus", configured.flags.topP],
67
+ ["Reasoning breadth", configured.flags.topK],
83
68
  ])));
84
69
 
85
70
  console.log("\n" + renderMemoryEstimate(configured));
86
- if (!(await prompt.yesNo("Save profile with these settings?", true))) return null;
71
+ if (!(await prompt.yesNo("Save this model setup?", true))) return null;
87
72
  return configured;
88
73
  }
89
74
 
@@ -168,29 +153,17 @@ function removeOption(argv, flag) {
168
153
  function renderMemoryEstimate(profile) {
169
154
  try {
170
155
  const est = estimateMemory(profile.modelPath, profile.mmprojPath, null, profile.flags);
171
- return renderSection("Memory", renderRows([
156
+ return renderSection("Memory estimate", renderRows([
172
157
  ["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
173
- ["Model", formatBytes(est.modelBytes)],
174
- ["KV cache", est.kvBytes ? `~${formatBytes(est.kvBytes)} (${profile.flags.ctxSize.toLocaleString()} ctx, ${profile.flags.cacheTypeK}/${profile.flags.cacheTypeV})` : "unknown"],
158
+ ["Model file", formatBytes(est.modelBytes)],
159
+ ["Conversation memory", est.kvBytes ? `~${formatBytes(est.kvBytes)} (${profile.flags.ctxSize.toLocaleString()} tokens, ${profile.flags.cacheTypeK}/${profile.flags.cacheTypeV})` : "unknown"],
175
160
  ...(est.note ? [["Note", pc.yellow(est.note)]] : []),
176
161
  ]));
177
162
  } catch {
178
- return renderSection("Memory", pc.dim("Estimate unavailable for this model."));
163
+ return renderSection("Memory estimate", pc.dim("Estimate unavailable for this model."));
179
164
  }
180
165
  }
181
166
 
182
- function detectionSummary(caps) {
183
- const parts = [];
184
- if (caps.architecture) parts.push(caps.architecture);
185
- if (caps.quant) parts.push(caps.quant);
186
- if (caps.mtp) parts.push("MTP");
187
- if (caps.qat) parts.push("QAT");
188
-
189
- if (caps.thinking) parts.push("thinking");
190
- if (caps.vision) parts.push("vision");
191
- return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
192
- }
193
-
194
167
  function samplingSummary(flags) {
195
168
  return `temp ${flags.temperature}, top-p ${flags.topP}, top-k ${flags.topK}`;
196
169
  }
package/src/ui.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { cancel, confirm, intro, isCancel, select, text } from "@clack/prompts";
1
+ import { box, cancel, confirm, intro, isCancel, select, text } from "@clack/prompts";
2
2
  import pc from "picocolors";
3
3
  import { stripVTControlCharacters } from "node:util";
4
4
 
@@ -59,8 +59,51 @@ export function renderRows(rows) {
59
59
  }).join("\n");
60
60
  }
61
61
 
62
+ export function renderCard(title, body, options = {}) {
63
+ let output = "";
64
+ box(String(body ?? ""), title, {
65
+ output: captureOutput((chunk) => { output += chunk; }, options.columns),
66
+ withGuide: false,
67
+ width: "auto",
68
+ contentPadding: options.contentPadding ?? 1,
69
+ titlePadding: options.titlePadding ?? 1,
70
+ rounded: options.rounded ?? true,
71
+ titleAlign: options.titleAlign ?? "left",
72
+ contentAlign: options.contentAlign ?? "left",
73
+ formatBorder: options.formatBorder ?? pc.magenta,
74
+ });
75
+ return output.trimEnd();
76
+ }
77
+
62
78
  export function renderSection(title, body) {
63
- return `${pc.magenta("◆")} ${pc.bold(title)}\n${body}`;
79
+ return renderCard(title, body, { formatBorder: pc.magenta });
80
+ }
81
+
82
+ export function humanCapabilitySummary(caps = {}) {
83
+ const parts = [];
84
+ if (caps.thinking) parts.push(pc.magenta("Reasoning"));
85
+ if (caps.vision) parts.push(pc.cyan("Vision"));
86
+ if (caps.mtp) parts.push(pc.blue("Fast draft mode"));
87
+ if (caps.qat) parts.push(pc.green("Optimized quantization"));
88
+ return parts.length > 0 ? parts.join(" · ") : "General chat";
89
+ }
90
+
91
+ export function statusText(kind, text) {
92
+ const color = {
93
+ ready: pc.green,
94
+ running: pc.green,
95
+ warning: pc.yellow,
96
+ info: pc.cyan,
97
+ muted: pc.dim,
98
+ }[kind] ?? ((value) => value);
99
+ return color(text);
100
+ }
101
+
102
+ function captureOutput(write, columns) {
103
+ return {
104
+ columns: Math.min(columns ?? process.stdout.columns ?? 88, 100),
105
+ write,
106
+ };
64
107
  }
65
108
 
66
109
  export function parseOptions(argv) {