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 +1 -1
- package/src/model-presenters.mjs +15 -15
- package/src/ui.mjs +83 -6
package/package.json
CHANGED
package/src/model-presenters.mjs
CHANGED
|
@@ -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,
|
|
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" +
|
|
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" +
|
|
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" +
|
|
274
|
+
console.log("\n" + renderSectionRows("Server command", [
|
|
275
275
|
["Run manually", pc.cyan(`bash ${scriptPath}`)],
|
|
276
276
|
["Command", pc.dim(script)],
|
|
277
|
-
]
|
|
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" +
|
|
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" +
|
|
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" +
|
|
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" +
|
|
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
|
-
]
|
|
320
|
+
], { columns: Math.min(process.stdout.columns ?? 110, 140) }));
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
export function printManagedModelDetails(model, backend) {
|
|
324
|
-
console.log("\n" +
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
82
|
+
...rawLines.map(visibleLen),
|
|
47
83
|
);
|
|
48
|
-
const
|
|
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,
|
|
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)
|