offgrid-ai 0.15.2 → 0.15.4

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.4",
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
  }
@@ -84,6 +159,43 @@ export function statusText(kind, text) {
84
159
  return color(text);
85
160
  }
86
161
 
162
+ // ── Escape-to-cancel helper ─────────────────────────────────────────────────
163
+
164
+ function withEscape() {
165
+ const controller = new AbortController();
166
+ let escapeTimer = null;
167
+ const onData = (data) => {
168
+ // Standalone Escape = 0x1b byte alone (arrow keys send 0x1b + more bytes)
169
+ if (data.length === 1 && data[0] === 0x1b) {
170
+ escapeTimer = setTimeout(() => controller.abort(), 50);
171
+ } else if (escapeTimer) {
172
+ clearTimeout(escapeTimer);
173
+ escapeTimer = null;
174
+ }
175
+ };
176
+ process.stdin.on("data", onData);
177
+ const cleanup = () => {
178
+ process.stdin.removeListener("data", onData);
179
+ if (escapeTimer) clearTimeout(escapeTimer);
180
+ };
181
+ return { signal: controller.signal, cleanup };
182
+ }
183
+
184
+ async function runPrompt(fn, config) {
185
+ const { signal, cleanup } = withEscape();
186
+ try {
187
+ return await fn(config, { signal });
188
+ } catch (err) {
189
+ if (err.name === "AbortPromptError") {
190
+ console.log(pc.dim("\nCancelled."));
191
+ process.exit(0);
192
+ }
193
+ throw err;
194
+ } finally {
195
+ cleanup();
196
+ }
197
+ }
198
+
87
199
  // ── Interactive prompt factory ──────────────────────────────────────────────
88
200
 
89
201
  export function startInteractive(title = "offgrid-ai") {
@@ -94,7 +206,7 @@ export function startInteractive(title = "offgrid-ai") {
94
206
  export function createPrompt() {
95
207
  return {
96
208
  async text(label, defaultValue) {
97
- const value = await input({
209
+ const value = await runPrompt(input, {
98
210
  message: label,
99
211
  default: defaultValue === undefined ? undefined : String(defaultValue),
100
212
  });
@@ -102,7 +214,7 @@ export function createPrompt() {
102
214
  },
103
215
 
104
216
  async number(label, defaultValue, min, max) {
105
- const value = await number({
217
+ const value = await runPrompt(number, {
106
218
  message: label,
107
219
  default: defaultValue,
108
220
  validate(input) {
@@ -115,7 +227,7 @@ export function createPrompt() {
115
227
  },
116
228
 
117
229
  async yesNo(label, defaultValue) {
118
- return await confirm({ message: label, default: defaultValue });
230
+ return await runPrompt(confirm, { message: label, default: defaultValue });
119
231
  },
120
232
 
121
233
  async choice(label, choices, defaultValue) {
@@ -128,7 +240,7 @@ export function createPrompt() {
128
240
  disabled: c.disabled || undefined,
129
241
  };
130
242
  });
131
- return await inquirerSelect({
243
+ return await runPrompt(inquirerSelect, {
132
244
  message: label,
133
245
  default: defaultValue,
134
246
  choices: mapped,
@@ -144,6 +256,8 @@ export function createPrompt() {
144
256
 
145
257
  export async function modelSelect(label, groups, { defaultKey, pageSize = 20 } = {}) {
146
258
  const choices = [];
259
+ // Separator below the prompt message
260
+ choices.push(new Separator(pc.dim(" ────────────────────────────────────────────────────────────")));
147
261
  for (let i = 0; i < groups.length; i++) {
148
262
  const group = groups[i];
149
263
  // Add blank line before each group (except the first)
@@ -164,7 +278,7 @@ export async function modelSelect(label, groups, { defaultKey, pageSize = 20 } =
164
278
  });
165
279
  }
166
280
  }
167
- return await inquirerSelect({
281
+ return await runPrompt(inquirerSelect, {
168
282
  message: label,
169
283
  default: defaultKey,
170
284
  choices,