@pi-unipi/utility 0.1.1 → 0.2.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/README.md +121 -21
- package/package.json +16 -7
- package/skills/utility/SKILL.md +70 -0
- package/src/analytics/collector.ts +293 -0
- package/src/cache/ttl-cache.ts +311 -0
- package/src/commands.ts +186 -0
- package/src/diagnostics/engine.ts +298 -0
- package/src/display/capabilities.ts +200 -0
- package/src/display/width.ts +226 -0
- package/src/index.ts +172 -0
- package/src/info-screen.ts +80 -0
- package/src/lifecycle/cleanup.ts +332 -0
- package/src/lifecycle/process.ts +162 -0
- package/src/tools/batch.ts +229 -0
- package/src/tools/env.ts +134 -0
- package/src/tui/settings-inspector.ts +303 -0
- package/src/types.ts +257 -0
- package/commands.ts +0 -38
- package/index.ts +0 -34
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Diagnostics Engine
|
|
3
|
+
*
|
|
4
|
+
* Cross-module diagnostics runner with health check plugins.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, accessSync, constants } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import type {
|
|
11
|
+
DiagnosticCheck,
|
|
12
|
+
DiagnosticPlugin,
|
|
13
|
+
DiagnosticsReport,
|
|
14
|
+
HealthStatus,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
/** Expand ~ to home directory */
|
|
18
|
+
function expandHome(path: string): string {
|
|
19
|
+
if (path.startsWith("~/")) {
|
|
20
|
+
return join(homedir(), path.slice(2));
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Check if a path is readable */
|
|
26
|
+
function isReadable(path: string): boolean {
|
|
27
|
+
try {
|
|
28
|
+
accessSync(path, constants.R_OK);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Check if a path is writable */
|
|
36
|
+
function isWritable(path: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
accessSync(path, constants.W_OK);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Built-in Diagnostic Plugins ─────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Check core directories exist and are accessible */
|
|
48
|
+
const coreDirectoriesPlugin: DiagnosticPlugin = {
|
|
49
|
+
name: "core_directories",
|
|
50
|
+
module: "@pi-unipi/core",
|
|
51
|
+
async run(): Promise<DiagnosticCheck[]> {
|
|
52
|
+
const dirs = [
|
|
53
|
+
{ path: "~/.unipi", required: true },
|
|
54
|
+
{ path: "~/.unipi/memory", required: false },
|
|
55
|
+
{ path: "~/.unipi/cache", required: false },
|
|
56
|
+
{ path: "~/.unipi/analytics", required: false },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const checks: DiagnosticCheck[] = [];
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
|
|
62
|
+
for (const dir of dirs) {
|
|
63
|
+
const fullPath = expandHome(dir.path);
|
|
64
|
+
const exists = existsSync(fullPath);
|
|
65
|
+
const readable = exists && isReadable(fullPath);
|
|
66
|
+
const writable = exists && isWritable(fullPath);
|
|
67
|
+
|
|
68
|
+
let status: HealthStatus;
|
|
69
|
+
let message: string;
|
|
70
|
+
let suggestion: string | undefined;
|
|
71
|
+
|
|
72
|
+
if (!exists) {
|
|
73
|
+
if (dir.required) {
|
|
74
|
+
status = "error";
|
|
75
|
+
message = `Required directory missing: ${dir.path}`;
|
|
76
|
+
suggestion = `Create it: mkdir -p ${dir.path}`;
|
|
77
|
+
} else {
|
|
78
|
+
status = "warning";
|
|
79
|
+
message = `Optional directory missing: ${dir.path}`;
|
|
80
|
+
suggestion = `Create it if needed: mkdir -p ${dir.path}`;
|
|
81
|
+
}
|
|
82
|
+
} else if (!writable) {
|
|
83
|
+
status = "error";
|
|
84
|
+
message = `Directory not writable: ${dir.path}`;
|
|
85
|
+
suggestion = `Check permissions: chmod u+w ${dir.path}`;
|
|
86
|
+
} else {
|
|
87
|
+
status = "healthy";
|
|
88
|
+
message = `Directory OK: ${dir.path}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
checks.push({
|
|
92
|
+
name: `dir_${dir.path.replace(/[^a-z0-9]/g, "_")}`,
|
|
93
|
+
module: "@pi-unipi/core",
|
|
94
|
+
status,
|
|
95
|
+
message,
|
|
96
|
+
suggestion,
|
|
97
|
+
durationMs: Date.now() - start,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return checks;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Check config files are valid JSON */
|
|
106
|
+
const configFilesPlugin: DiagnosticPlugin = {
|
|
107
|
+
name: "config_files",
|
|
108
|
+
module: "@pi-unipi/core",
|
|
109
|
+
async run(): Promise<DiagnosticCheck[]> {
|
|
110
|
+
const configs = [
|
|
111
|
+
"~/.unipi/config/mcp/servers.json",
|
|
112
|
+
".unipi/config/mcp/servers.json",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const checks: DiagnosticCheck[] = [];
|
|
116
|
+
|
|
117
|
+
for (const configPath of configs) {
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
const fullPath = expandHome(configPath);
|
|
120
|
+
|
|
121
|
+
if (!existsSync(fullPath)) {
|
|
122
|
+
checks.push({
|
|
123
|
+
name: `config_${basename(configPath)}`,
|
|
124
|
+
module: "@pi-unipi/core",
|
|
125
|
+
status: "unknown",
|
|
126
|
+
message: `Config not found: ${configPath}`,
|
|
127
|
+
durationMs: Date.now() - start,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const content = await import("node:fs").then((fs) =>
|
|
134
|
+
fs.readFileSync(fullPath, "utf-8"),
|
|
135
|
+
);
|
|
136
|
+
JSON.parse(content);
|
|
137
|
+
checks.push({
|
|
138
|
+
name: `config_${basename(configPath)}`,
|
|
139
|
+
module: "@pi-unipi/core",
|
|
140
|
+
status: "healthy",
|
|
141
|
+
message: `Config valid: ${configPath}`,
|
|
142
|
+
durationMs: Date.now() - start,
|
|
143
|
+
});
|
|
144
|
+
} catch (err) {
|
|
145
|
+
checks.push({
|
|
146
|
+
name: `config_${basename(configPath)}`,
|
|
147
|
+
module: "@pi-unipi/core",
|
|
148
|
+
status: "error",
|
|
149
|
+
message: `Invalid JSON in ${configPath}: ${(err as Error).message}`,
|
|
150
|
+
suggestion: `Fix or remove the config file`,
|
|
151
|
+
durationMs: Date.now() - start,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return checks;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Check Node.js environment */
|
|
161
|
+
const nodeEnvironmentPlugin: DiagnosticPlugin = {
|
|
162
|
+
name: "node_environment",
|
|
163
|
+
module: "@pi-unipi/core",
|
|
164
|
+
async run(): Promise<DiagnosticCheck[]> {
|
|
165
|
+
const start = Date.now();
|
|
166
|
+
const checks: DiagnosticCheck[] = [];
|
|
167
|
+
|
|
168
|
+
// Node version
|
|
169
|
+
const nodeVersion = process.version;
|
|
170
|
+
const major = parseInt(nodeVersion.slice(1).split(".")[0], 10);
|
|
171
|
+
const nodeCheck: DiagnosticCheck = {
|
|
172
|
+
name: "node_version",
|
|
173
|
+
module: "@pi-unipi/core",
|
|
174
|
+
status: major >= 18 ? "healthy" : "warning",
|
|
175
|
+
message: `Node.js ${nodeVersion}`,
|
|
176
|
+
suggestion: major < 18 ? "Upgrade to Node.js 18+ for best compatibility" : undefined,
|
|
177
|
+
durationMs: Date.now() - start,
|
|
178
|
+
};
|
|
179
|
+
checks.push(nodeCheck);
|
|
180
|
+
|
|
181
|
+
// Memory usage
|
|
182
|
+
const memStart = Date.now();
|
|
183
|
+
const usage = process.memoryUsage();
|
|
184
|
+
const heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024);
|
|
185
|
+
const heapTotalMB = Math.round(usage.heapTotal / 1024 / 1024);
|
|
186
|
+
checks.push({
|
|
187
|
+
name: "memory_usage",
|
|
188
|
+
module: "@pi-unipi/core",
|
|
189
|
+
status: heapUsedMB > 512 ? "warning" : "healthy",
|
|
190
|
+
message: `Heap: ${heapUsedMB} MB / ${heapTotalMB} MB`,
|
|
191
|
+
suggestion: heapUsedMB > 512 ? "High memory usage detected — consider restarting" : undefined,
|
|
192
|
+
durationMs: Date.now() - memStart,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return checks;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ─── Diagnostics Engine ──────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Registry of diagnostic plugins */
|
|
202
|
+
const plugins: DiagnosticPlugin[] = [
|
|
203
|
+
coreDirectoriesPlugin,
|
|
204
|
+
configFilesPlugin,
|
|
205
|
+
nodeEnvironmentPlugin,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/** Register a custom diagnostic plugin */
|
|
209
|
+
export function registerDiagnosticPlugin(plugin: DiagnosticPlugin): void {
|
|
210
|
+
plugins.push(plugin);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Run all diagnostic checks and generate a report */
|
|
214
|
+
export async function runDiagnostics(): Promise<DiagnosticsReport> {
|
|
215
|
+
const timestamp = Date.now();
|
|
216
|
+
const checks: DiagnosticCheck[] = [];
|
|
217
|
+
|
|
218
|
+
for (const plugin of plugins) {
|
|
219
|
+
try {
|
|
220
|
+
const pluginChecks = await plugin.run();
|
|
221
|
+
checks.push(...pluginChecks);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
checks.push({
|
|
224
|
+
name: `${plugin.name}_error`,
|
|
225
|
+
module: plugin.module,
|
|
226
|
+
status: "error",
|
|
227
|
+
message: `Plugin failed: ${(err as Error).message}`,
|
|
228
|
+
durationMs: 0,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const summary = {
|
|
234
|
+
healthy: checks.filter((c) => c.status === "healthy").length,
|
|
235
|
+
warning: checks.filter((c) => c.status === "warning").length,
|
|
236
|
+
error: checks.filter((c) => c.status === "error").length,
|
|
237
|
+
unknown: checks.filter((c) => c.status === "unknown").length,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
let overall: HealthStatus;
|
|
241
|
+
if (summary.error > 0) {
|
|
242
|
+
overall = "error";
|
|
243
|
+
} else if (summary.warning > 0) {
|
|
244
|
+
overall = "warning";
|
|
245
|
+
} else if (summary.healthy > 0) {
|
|
246
|
+
overall = "healthy";
|
|
247
|
+
} else {
|
|
248
|
+
overall = "unknown";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
timestamp,
|
|
253
|
+
overall,
|
|
254
|
+
checks,
|
|
255
|
+
summary,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Format a diagnostics report as markdown */
|
|
260
|
+
export function formatDiagnosticsReport(report: DiagnosticsReport): string {
|
|
261
|
+
const lines = [
|
|
262
|
+
"## 🔍 Diagnostics Report",
|
|
263
|
+
"",
|
|
264
|
+
`**Overall:** ${report.overall.toUpperCase()}`,
|
|
265
|
+
`**Checks:** ${report.summary.healthy} healthy, ${report.summary.warning} warning, ${report.summary.error} error, ${report.summary.unknown} unknown`,
|
|
266
|
+
`**Timestamp:** ${new Date(report.timestamp).toISOString()}`,
|
|
267
|
+
"",
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
// Group by status (errors first)
|
|
271
|
+
const byStatus = {
|
|
272
|
+
error: report.checks.filter((c) => c.status === "error"),
|
|
273
|
+
warning: report.checks.filter((c) => c.status === "warning"),
|
|
274
|
+
unknown: report.checks.filter((c) => c.status === "unknown"),
|
|
275
|
+
healthy: report.checks.filter((c) => c.status === "healthy"),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
for (const [status, checks] of Object.entries(byStatus)) {
|
|
279
|
+
if (checks.length === 0) continue;
|
|
280
|
+
lines.push(`### ${status.toUpperCase()} (${checks.length})`, "");
|
|
281
|
+
for (const check of checks) {
|
|
282
|
+
lines.push(`- **${check.name}** (${check.module})`);
|
|
283
|
+
lines.push(` ${check.message}`);
|
|
284
|
+
if (check.suggestion) {
|
|
285
|
+
lines.push(` 💡 ${check.suggestion}`);
|
|
286
|
+
}
|
|
287
|
+
lines.push("");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Get basename for diagnostic naming */
|
|
295
|
+
function basename(path: string): string {
|
|
296
|
+
const parts = path.split(/[/\\]/);
|
|
297
|
+
return parts[parts.length - 1] || path;
|
|
298
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Terminal Capabilities Detection
|
|
3
|
+
*
|
|
4
|
+
* Detect terminal features for optimal rendering:
|
|
5
|
+
* - Color support (basic, 256, truecolor)
|
|
6
|
+
* - Nerd Font detection
|
|
7
|
+
* - Unicode support
|
|
8
|
+
* - Terminal dimensions
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TerminalCapabilities } from "../types.js";
|
|
12
|
+
|
|
13
|
+
/** Cached capabilities per process */
|
|
14
|
+
let cachedCapabilities: TerminalCapabilities | null = null;
|
|
15
|
+
let cacheTimestamp = 0;
|
|
16
|
+
const CACHE_TTL_MS = 5000; // Re-detect every 5s
|
|
17
|
+
|
|
18
|
+
/** Detect color support level */
|
|
19
|
+
function detectColorSupport(): { color: boolean; truecolor: boolean } {
|
|
20
|
+
const env = process.env;
|
|
21
|
+
|
|
22
|
+
// No color
|
|
23
|
+
if (env.NO_COLOR || env.NODE_DISABLE_COLORS) {
|
|
24
|
+
return { color: false, truecolor: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Force color
|
|
28
|
+
if (env.FORCE_COLOR) {
|
|
29
|
+
const level = parseInt(env.FORCE_COLOR, 10);
|
|
30
|
+
return {
|
|
31
|
+
color: level >= 1,
|
|
32
|
+
truecolor: level >= 3,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// CI environments typically support colors
|
|
37
|
+
if (env.CI) {
|
|
38
|
+
return { color: true, truecolor: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Terminal emulator detection
|
|
42
|
+
const term = env.TERM || "";
|
|
43
|
+
const termProgram = env.TERM_PROGRAM || "";
|
|
44
|
+
|
|
45
|
+
// Truecolor support
|
|
46
|
+
const truecolorTerms = [
|
|
47
|
+
"truecolor",
|
|
48
|
+
"24bit",
|
|
49
|
+
"xterm-256color",
|
|
50
|
+
"screen-256color",
|
|
51
|
+
"tmux-256color",
|
|
52
|
+
"alacritty",
|
|
53
|
+
"kitty",
|
|
54
|
+
"wezterm",
|
|
55
|
+
"iTerm",
|
|
56
|
+
"ghostty",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const hasTruecolor =
|
|
60
|
+
env.COLORTERM === "truecolor" ||
|
|
61
|
+
env.COLORTERM === "24bit" ||
|
|
62
|
+
truecolorTerms.some((t) => term.includes(t) || termProgram.includes(t));
|
|
63
|
+
|
|
64
|
+
// Basic color support
|
|
65
|
+
const hasColor =
|
|
66
|
+
hasTruecolor ||
|
|
67
|
+
term.includes("color") ||
|
|
68
|
+
term.includes("ansi") ||
|
|
69
|
+
term.includes("xterm") ||
|
|
70
|
+
term.includes("screen") ||
|
|
71
|
+
term.includes("tmux") ||
|
|
72
|
+
termProgram.length > 0;
|
|
73
|
+
|
|
74
|
+
return { color: hasColor, truecolor: hasTruecolor };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Detect Nerd Font support */
|
|
78
|
+
function detectNerdFont(): boolean {
|
|
79
|
+
const env = process.env;
|
|
80
|
+
|
|
81
|
+
// Explicit override
|
|
82
|
+
if (env.NERD_FONT === "1" || env.NERD_FONT === "true") {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (env.NERD_FONT === "0" || env.NERD_FONT === "false") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Terminal emulator hints
|
|
90
|
+
const termProgram = env.TERM_PROGRAM || "";
|
|
91
|
+
const knownNerdFontTerminals = [
|
|
92
|
+
"iTerm.app",
|
|
93
|
+
"WezTerm",
|
|
94
|
+
"Alacritty",
|
|
95
|
+
"Kitty",
|
|
96
|
+
"Ghostty",
|
|
97
|
+
"Warp",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
if (knownNerdFontTerminals.some((t) => termProgram.includes(t))) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Default to false for safety
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Detect Unicode support level */
|
|
109
|
+
function detectUnicode(): "none" | "basic" | "full" {
|
|
110
|
+
const env = process.env;
|
|
111
|
+
|
|
112
|
+
// Explicit override
|
|
113
|
+
if (env.UNICODE === "0" || env.NO_UNICODE) {
|
|
114
|
+
return "none";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// LANG/LC_ALL hints
|
|
118
|
+
const locale = env.LANG || env.LC_ALL || env.LC_CTYPE || "";
|
|
119
|
+
if (locale.includes("UTF-8") || locale.includes("utf8")) {
|
|
120
|
+
return "full";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Windows CMD typically has limited Unicode
|
|
124
|
+
if (process.platform === "win32" && !env.WT_SESSION) {
|
|
125
|
+
return "basic";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Default to basic (safe middle ground)
|
|
129
|
+
return "basic";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get terminal dimensions */
|
|
133
|
+
function getTerminalSize(): { width: number; height: number } {
|
|
134
|
+
const stdout = process.stdout;
|
|
135
|
+
if (stdout && stdout.isTTY) {
|
|
136
|
+
const cols = stdout.columns || 80;
|
|
137
|
+
const rows = stdout.rows || 24;
|
|
138
|
+
return { width: cols, height: rows };
|
|
139
|
+
}
|
|
140
|
+
return { width: 80, height: 24 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Detect terminal capabilities.
|
|
145
|
+
* Results are cached for CACHE_TTL_MS to avoid repeated detection.
|
|
146
|
+
*/
|
|
147
|
+
export function detectCapabilities(): TerminalCapabilities {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (cachedCapabilities && now - cacheTimestamp < CACHE_TTL_MS) {
|
|
150
|
+
// Update dimensions even when cached (they change on resize)
|
|
151
|
+
const size = getTerminalSize();
|
|
152
|
+
return {
|
|
153
|
+
...cachedCapabilities,
|
|
154
|
+
width: size.width,
|
|
155
|
+
height: size.height,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const colorSupport = detectColorSupport();
|
|
160
|
+
const size = getTerminalSize();
|
|
161
|
+
|
|
162
|
+
cachedCapabilities = {
|
|
163
|
+
color: colorSupport.color,
|
|
164
|
+
truecolor: colorSupport.truecolor,
|
|
165
|
+
nerdFont: detectNerdFont(),
|
|
166
|
+
unicode: detectUnicode(),
|
|
167
|
+
width: size.width,
|
|
168
|
+
height: size.height,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
cacheTimestamp = now;
|
|
172
|
+
return cachedCapabilities;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Force re-detection of capabilities */
|
|
176
|
+
export function refreshCapabilities(): TerminalCapabilities {
|
|
177
|
+
cachedCapabilities = null;
|
|
178
|
+
cacheTimestamp = 0;
|
|
179
|
+
return detectCapabilities();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Check if a specific capability is available */
|
|
183
|
+
export function hasCapability(
|
|
184
|
+
cap: keyof TerminalCapabilities,
|
|
185
|
+
): boolean {
|
|
186
|
+
const caps = detectCapabilities();
|
|
187
|
+
const value = caps[cap];
|
|
188
|
+
if (typeof value === "boolean") {
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
if (typeof value === "string") {
|
|
192
|
+
return value !== "none";
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Get safe icon based on Nerd Font availability */
|
|
198
|
+
export function getIcon(nerdFont: string, fallback: string): string {
|
|
199
|
+
return detectCapabilities().nerdFont ? nerdFont : fallback;
|
|
200
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Width Management Utilities
|
|
3
|
+
*
|
|
4
|
+
* Safe width clamping, line wrapping, and line collapsing.
|
|
5
|
+
* Handles ANSI escape sequences correctly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WidthOptions } from "../types.js";
|
|
9
|
+
|
|
10
|
+
/** ANSI escape sequence regex */
|
|
11
|
+
const ANSI_REGEX =
|
|
12
|
+
/\u001b\[[\d;]*[a-zA-Z]|\u001b\][^\u0007]*\u0007|\u001b\[[\d;]*[\u0020-\u002f]*[\u0030-\u007e]/g;
|
|
13
|
+
|
|
14
|
+
/** Strip ANSI escape sequences from text */
|
|
15
|
+
export function stripAnsi(text: string): string {
|
|
16
|
+
return text.replace(ANSI_REGEX, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Get visual width of text (excluding ANSI codes) */
|
|
20
|
+
export function visualWidth(text: string): number {
|
|
21
|
+
return stripAnsi(text).length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Default width options */
|
|
25
|
+
const DEFAULT_WIDTH_OPTS: Required<WidthOptions> = {
|
|
26
|
+
ellipsis: "…",
|
|
27
|
+
breakWords: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Clamp text to maxWidth visual characters.
|
|
32
|
+
* Preserves ANSI sequences at the end of truncated text.
|
|
33
|
+
*/
|
|
34
|
+
export function clampWidth(
|
|
35
|
+
text: string,
|
|
36
|
+
maxWidth: number,
|
|
37
|
+
options: WidthOptions = {},
|
|
38
|
+
): string {
|
|
39
|
+
const opts = { ...DEFAULT_WIDTH_OPTS, ...options };
|
|
40
|
+
const plain = stripAnsi(text);
|
|
41
|
+
|
|
42
|
+
if (plain.length <= maxWidth) {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Need to truncate while preserving ANSI
|
|
47
|
+
const ellipsisWidth = visualWidth(opts.ellipsis);
|
|
48
|
+
const targetWidth = maxWidth - ellipsisWidth;
|
|
49
|
+
|
|
50
|
+
if (targetWidth <= 0) {
|
|
51
|
+
return opts.ellipsis.slice(0, maxWidth);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Walk through text, tracking ANSI state
|
|
55
|
+
let visualCount = 0;
|
|
56
|
+
let result = "";
|
|
57
|
+
let inAnsi = false;
|
|
58
|
+
let ansiBuffer = "";
|
|
59
|
+
|
|
60
|
+
for (const char of text) {
|
|
61
|
+
if (char === "\u001b") {
|
|
62
|
+
inAnsi = true;
|
|
63
|
+
ansiBuffer = char;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (inAnsi) {
|
|
68
|
+
ansiBuffer += char;
|
|
69
|
+
// Check if ANSI sequence is complete
|
|
70
|
+
if (/[a-zA-Z\u0007]/.test(char) || (ansiBuffer.startsWith("\u001b]") && char === "\u0007")) {
|
|
71
|
+
inAnsi = false;
|
|
72
|
+
result += ansiBuffer;
|
|
73
|
+
ansiBuffer = "";
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (visualCount < targetWidth) {
|
|
79
|
+
result += char;
|
|
80
|
+
visualCount++;
|
|
81
|
+
} else {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Add any pending ANSI sequences and reset
|
|
87
|
+
if (ansiBuffer) {
|
|
88
|
+
result += ansiBuffer;
|
|
89
|
+
}
|
|
90
|
+
result += "\u001b[0m"; // Reset ANSI
|
|
91
|
+
result += opts.ellipsis;
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Wrap text into lines of maxWidth visual characters.
|
|
98
|
+
* Respects word boundaries unless breakWords is true.
|
|
99
|
+
*/
|
|
100
|
+
export function wrapLines(
|
|
101
|
+
text: string,
|
|
102
|
+
maxWidth: number,
|
|
103
|
+
options: WidthOptions = {},
|
|
104
|
+
): string[] {
|
|
105
|
+
const opts = { ...DEFAULT_WIDTH_OPTS, ...options };
|
|
106
|
+
const lines: string[] = [];
|
|
107
|
+
const paragraphs = text.split("\n");
|
|
108
|
+
|
|
109
|
+
for (const paragraph of paragraphs) {
|
|
110
|
+
if (visualWidth(paragraph) <= maxWidth) {
|
|
111
|
+
lines.push(paragraph);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const words = paragraph.split(/(\s+)/);
|
|
116
|
+
let currentLine = "";
|
|
117
|
+
let currentWidth = 0;
|
|
118
|
+
|
|
119
|
+
for (const word of words) {
|
|
120
|
+
const wordWidth = visualWidth(word);
|
|
121
|
+
|
|
122
|
+
if (wordWidth === 0) {
|
|
123
|
+
// Whitespace-only word
|
|
124
|
+
currentLine += word;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (currentWidth + wordWidth > maxWidth) {
|
|
129
|
+
if (currentLine) {
|
|
130
|
+
lines.push(currentLine);
|
|
131
|
+
currentLine = "";
|
|
132
|
+
currentWidth = 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Word itself might be longer than maxWidth
|
|
136
|
+
if (!opts.breakWords && wordWidth > maxWidth) {
|
|
137
|
+
// Break the long word
|
|
138
|
+
let remaining = word;
|
|
139
|
+
while (visualWidth(remaining) > maxWidth) {
|
|
140
|
+
let chunk = "";
|
|
141
|
+
let chunkWidth = 0;
|
|
142
|
+
for (const char of remaining) {
|
|
143
|
+
const charWidth = visualWidth(char);
|
|
144
|
+
if (chunkWidth + charWidth > maxWidth) {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
chunk += char;
|
|
148
|
+
chunkWidth += charWidth;
|
|
149
|
+
}
|
|
150
|
+
lines.push(chunk);
|
|
151
|
+
remaining = remaining.slice(chunk.length);
|
|
152
|
+
}
|
|
153
|
+
if (remaining) {
|
|
154
|
+
currentLine = remaining;
|
|
155
|
+
currentWidth = visualWidth(remaining);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
currentLine = word;
|
|
159
|
+
currentWidth = wordWidth;
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
currentLine += word;
|
|
163
|
+
currentWidth += wordWidth;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (currentLine) {
|
|
168
|
+
lines.push(currentLine);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Collapse consecutive empty lines down to maxEmpty.
|
|
177
|
+
*/
|
|
178
|
+
export function collapseLines(
|
|
179
|
+
lines: string[],
|
|
180
|
+
maxEmpty: number = 1,
|
|
181
|
+
): string[] {
|
|
182
|
+
const result: string[] = [];
|
|
183
|
+
let emptyCount = 0;
|
|
184
|
+
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
const isEmpty = stripAnsi(line).trim().length === 0;
|
|
187
|
+
|
|
188
|
+
if (isEmpty) {
|
|
189
|
+
emptyCount++;
|
|
190
|
+
if (emptyCount <= maxEmpty) {
|
|
191
|
+
result.push(line);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
emptyCount = 0;
|
|
195
|
+
result.push(line);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pad text to target width with spaces.
|
|
204
|
+
* Respects ANSI sequences.
|
|
205
|
+
*/
|
|
206
|
+
export function padWidth(text: string, targetWidth: number): string {
|
|
207
|
+
const currentWidth = visualWidth(text);
|
|
208
|
+
if (currentWidth >= targetWidth) {
|
|
209
|
+
return text;
|
|
210
|
+
}
|
|
211
|
+
return text + " ".repeat(targetWidth - currentWidth);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Center text within target width.
|
|
216
|
+
*/
|
|
217
|
+
export function centerWidth(text: string, targetWidth: number): string {
|
|
218
|
+
const currentWidth = visualWidth(text);
|
|
219
|
+
if (currentWidth >= targetWidth) {
|
|
220
|
+
return text;
|
|
221
|
+
}
|
|
222
|
+
const padding = targetWidth - currentWidth;
|
|
223
|
+
const left = Math.floor(padding / 2);
|
|
224
|
+
const right = padding - left;
|
|
225
|
+
return " ".repeat(left) + text + " ".repeat(right);
|
|
226
|
+
}
|