offgrid-ai 0.15.2 → 0.15.3

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.15.2",
3
+ "version": "0.15.3",
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",
@@ -3,7 +3,7 @@ import { basename, dirname, join } from "node:path";
3
3
  import { backendFor } from "./backends.mjs";
4
4
  import { computeServerCommand, buildStartScript, isProfileRunning } from "./process.mjs";
5
5
  import { profileDir } from "./profiles.mjs";
6
- import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
6
+ import { pc, formatBytes, renderSectionRows } from "./ui.mjs";
7
7
  import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
8
8
  import { itemKey } from "./model-catalog.mjs";
9
9
  import { DATA_DIR } from "./config.mjs";
@@ -239,12 +239,12 @@ export async function printProfileDetails(profile) {
239
239
  const isManaged = backend.type === "managed-server";
240
240
  const running = await isProfileRunning(profile);
241
241
  const fileMissing = !isManaged && isProfileFileMissing(profile);
242
- console.log("\n" + renderSection("Model overview", renderRows([
242
+ console.log("\n" + renderSectionRows("Model overview", [
243
243
  ["Name", pc.bold(profile.label)],
244
244
  ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : pc.blue("Ready")],
245
245
  ["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
246
246
  ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
247
- ])));
247
+ ]));
248
248
 
249
249
  const detailRows = [
250
250
  ["Setup ID", profile.id],
@@ -262,7 +262,7 @@ export async function printProfileDetails(profile) {
262
262
  detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
263
263
  }
264
264
  }
265
- console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
265
+ console.log("\n" + renderSectionRows("Model details", detailRows, { columns: Math.min(process.stdout.columns ?? 110, 140) }));
266
266
 
267
267
  if (fileMissing) console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
268
268
 
@@ -271,10 +271,10 @@ export async function printProfileDetails(profile) {
271
271
  if (command) {
272
272
  const script = buildStartScript(profile, command);
273
273
  const scriptPath = join(profileDir(profile.id), "start.sh");
274
- console.log("\n" + renderSection("Server command", renderRows([
274
+ console.log("\n" + renderSectionRows("Server command", [
275
275
  ["Run manually", pc.cyan(`bash ${scriptPath}`)],
276
276
  ["Command", pc.dim(script)],
277
- ]), { columns: 120 }));
277
+ ], { columns: Math.min(process.stdout.columns ?? 120, 140) }));
278
278
  }
279
279
  }
280
280
  }
@@ -282,11 +282,11 @@ export async function printProfileDetails(profile) {
282
282
  export function printGgufModelDetails(model, drafter) {
283
283
  const { caps, parts } = ggufDetailParts(model, drafter);
284
284
  parts.push(formatBytes(model.model.sizeBytes));
285
- console.log("\n" + renderSection("Downloaded model", renderRows([
285
+ console.log("\n" + renderSectionRows("Downloaded model", [
286
286
  ["Name", pc.bold(model.label)],
287
287
  ["Status", pc.yellow("Needs one-time setup")],
288
288
  ["Details", parts.join(pc.dim(" · "))],
289
- ])));
289
+ ]));
290
290
  const detailRows = [
291
291
  ["Local file", model.path],
292
292
  ["Vision file", model.mmprojPath ?? "none"],
@@ -294,7 +294,7 @@ export function printGgufModelDetails(model, drafter) {
294
294
  ["Quant", model.quant ?? "unknown"],
295
295
  ];
296
296
  if (drafter) detailRows.push(["Drafter", drafter.path], ["Drafter size", formatBytes(drafter.sizeBytes)]);
297
- console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
297
+ console.log("\n" + renderSectionRows("Model details", detailRows, { columns: Math.min(process.stdout.columns ?? 110, 140) }));
298
298
  }
299
299
 
300
300
  export async function printMlxModelDetails(model) {
@@ -305,27 +305,27 @@ export async function printMlxModelDetails(model) {
305
305
  if (caps.thinking) parts.push("thinking");
306
306
  if (caps.vision) parts.push("vision");
307
307
  const summary = parts.length > 0 ? parts.join(pc.dim(" · ")) : "standard MLX";
308
- console.log("\n" + renderSection("Downloaded model", renderRows([
308
+ console.log("\n" + renderSectionRows("Downloaded model", [
309
309
  ["Name", pc.bold(model.label)],
310
310
  ["Status", pc.yellow("Needs one-time setup")],
311
311
  ["Details", summary],
312
- ])));
313
- console.log("\n" + renderSection("Model details", renderRows([
312
+ ]));
313
+ console.log("\n" + renderSectionRows("Model details", [
314
314
  ["Model dir", model.path],
315
315
  ["Backend", "mlx-vlm"],
316
316
  ["Source", formatSourceLabel(model.source)],
317
317
  ["Detected", summary],
318
318
  ["Size", formatBytes(model.sizeBytes)],
319
319
  ["Context", caps.contextLength ? `${caps.contextLength.toLocaleString()} trained` : "unknown"],
320
- ]), { columns: 110 }));
320
+ ], { columns: Math.min(process.stdout.columns ?? 110, 140) }));
321
321
  }
322
322
 
323
323
  export function printManagedModelDetails(model, backend) {
324
- console.log("\n" + renderSection(`${backend.label} model`, renderRows([
324
+ console.log("\n" + renderSectionRows(`${backend.label} model`, [
325
325
  ["Name", pc.bold(model.label)],
326
326
  ["Status", pc.green(`Local model via ${backend.label}`)],
327
327
  ["Model ID", pc.cyan(model.id)],
328
328
  ["Quant", model.quant ?? "unknown"],
329
329
  ["Family", model.family ?? "unknown"],
330
- ])));
330
+ ]));
331
331
  }
package/src/ui.mjs CHANGED
@@ -16,15 +16,49 @@ export function formatBytes(bytes) {
16
16
  return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
17
17
  }
18
18
 
19
- export function renderRows(rows) {
19
+ export function renderRows(rows, { wrapWidth } = {}) {
20
20
  if (rows.length === 0) return "";
21
21
  const width = Math.max(...rows.map(([key]) => stripVTControlCharacters(String(key)).length));
22
22
  return rows.map(([key, value]) => {
23
23
  const visible = stripVTControlCharacters(String(key)).length;
24
- return `${key}${" ".repeat(Math.max(1, width - visible + 2))}${value}`;
24
+ const indent = " ".repeat(Math.max(1, width - visible + 2));
25
+ const valStr = String(value ?? "");
26
+ // If wrapWidth is set and the full line exceeds it, wrap the value
27
+ if (wrapWidth) {
28
+ const prefix = `${key}${indent}`;
29
+ const prefixLen = stripVTControlCharacters(prefix).length;
30
+ const availWidth = wrapWidth - prefixLen;
31
+ if (stripVTControlCharacters(valStr).length > availWidth) {
32
+ const lines = wrapText(valStr, availWidth);
33
+ return [prefix + lines[0], ...lines.slice(1).map((l) => " ".repeat(prefixLen) + l)].join("\n");
34
+ }
35
+ }
36
+ return `${key}${indent}${valStr}`;
25
37
  }).join("\n");
26
38
  }
27
39
 
40
+ function wrapText(text, width) {
41
+ const words = String(text).split(/(\s+)/u);
42
+ const lines = [];
43
+ let current = "";
44
+ for (let word of words) {
45
+ // Hard-break long unbreakable strings (file paths, etc.)
46
+ while (stripVTControlCharacters(word).length > width) {
47
+ if (current.trim()) { lines.push(current.trimEnd()); current = ""; }
48
+ lines.push(word.slice(0, width));
49
+ word = word.slice(width);
50
+ }
51
+ if (stripVTControlCharacters(current + word).length > width && current.trim()) {
52
+ lines.push(current.trimEnd());
53
+ current = word.trimStart();
54
+ } else {
55
+ current += word;
56
+ }
57
+ }
58
+ if (current.trim()) lines.push(current.trimEnd());
59
+ return lines.length > 0 ? lines : [text];
60
+ }
61
+
28
62
  // ── Box renderer ────────────────────────────────────────────────────────────
29
63
 
30
64
  function visibleLen(text) {
@@ -39,25 +73,66 @@ function padVisible(text, width) {
39
73
  export function renderCard(title, body, options = {}) {
40
74
  const borderColor = options.formatBorder ?? pc.magenta;
41
75
  const maxCols = options.columns ?? process.stdout.columns ?? 88;
42
- const lines = String(body ?? "").split("\n");
76
+ const rawLines = String(body ?? "").split("\n");
43
77
  const titleStr = title ? ` ${title} ` : "";
78
+
79
+ // Calculate content width: max of title, all lines, capped by maxCols
44
80
  const innerWidth = Math.max(
45
81
  visibleLen(titleStr),
46
- ...lines.map(visibleLen),
82
+ ...rawLines.map(visibleLen),
47
83
  );
48
- const width = Math.min(innerWidth + 2, maxCols - 2);
84
+ const contentWidth = Math.min(innerWidth, maxCols - 4); // 4 = borders + padding
85
+ const width = contentWidth + 2; // inner content area
86
+
87
+ // Wrap lines that exceed contentWidth
88
+ const lines = [];
89
+ for (const line of rawLines) {
90
+ if (visibleLen(line) > contentWidth) {
91
+ lines.push(...wrapVisible(line, contentWidth));
92
+ } else {
93
+ lines.push(line);
94
+ }
95
+ }
49
96
 
50
97
  const topTitle = title ? `╭${pc.reset(titleStr)}` : "╭";
51
98
  const topFill = "─".repeat(Math.max(0, width + 2 - visibleLen(titleStr)));
52
99
  const top = `${topTitle}${topFill}╮`;
53
100
 
54
- const middle = lines.map((line) => `│ ${padVisible(line, width)} │`);
101
+ const middle = lines.map((line) => `│ ${padVisible(line, contentWidth)} │`);
55
102
 
56
103
  const bottom = `╰${"─".repeat(width + 2)}╯`;
57
104
 
58
105
  return [top, ...middle, bottom].map((l) => borderColor(l)).join("\n");
59
106
  }
60
107
 
108
+ function wrapVisible(text, width) {
109
+ const words = String(text).split(/(\s+)/u);
110
+ const lines = [];
111
+ let current = "";
112
+ for (let word of words) {
113
+ // If a single word exceeds the width, hard-break it
114
+ while (visibleLen(word) > width) {
115
+ if (current.trim()) { lines.push(current.trimEnd()); current = ""; }
116
+ lines.push(word.slice(0, width));
117
+ word = word.slice(width);
118
+ }
119
+ if (visibleLen(current + word) > width && current.trim()) {
120
+ lines.push(current.trimEnd());
121
+ current = word.trimStart();
122
+ } else {
123
+ current += word;
124
+ }
125
+ }
126
+ if (current.trim()) lines.push(current.trimEnd());
127
+ return lines.length > 0 ? lines : [text];
128
+ }
129
+
130
+ export function renderSectionRows(title, rows, options = {}) {
131
+ const maxCols = options.columns ?? process.stdout.columns ?? 88;
132
+ const contentWidth = maxCols - 4;
133
+ return renderSection(title, renderRows(rows, { wrapWidth: contentWidth }), { ...options, columns: maxCols });
134
+ }
135
+
61
136
  export function renderSection(title, body, options = {}) {
62
137
  return renderCard(title, body, { formatBorder: pc.magenta, ...options });
63
138
  }
@@ -144,6 +219,8 @@ export function createPrompt() {
144
219
 
145
220
  export async function modelSelect(label, groups, { defaultKey, pageSize = 20 } = {}) {
146
221
  const choices = [];
222
+ // Separator below the prompt message
223
+ choices.push(new Separator(pc.dim(" ────────────────────────────────────────────────────────────")));
147
224
  for (let i = 0; i < groups.length; i++) {
148
225
  const group = groups[i];
149
226
  // Add blank line before each group (except the first)