@pi-unipi/unipi 2.0.4 → 2.0.6

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 CHANGED
@@ -27,7 +27,7 @@ pi install npm:@pi-unipi/unipi
27
27
 
28
28
  **[Notify](./packages/notify/README.md)** — Push notifications to native OS, Gotify, Telegram, or ntfy. Per-event platform routing plus native focus suppression so alerts can stay quiet while Pi is already focused.
29
29
 
30
- **[Footer](./packages/footer/README.md)** — Persistent status bar showing live stats from every package. Responsive layout, presets, per-segment toggling.
30
+ **[Footer](./packages/footer/README.md)** — Persistent status bar showing live stats from every package. Responsive layout, presets, per-segment toggling, and terminal-aware color fallback.
31
31
 
32
32
  **[BTW](./packages/btw/README.md)** — Side conversations that run in parallel. Ask questions without interrupting the main agent.
33
33
 
@@ -45,7 +45,7 @@ pi install npm:@pi-unipi/unipi
45
45
 
46
46
  **[Input Shortcuts](./packages/input-shortcuts/README.md)** — Keyboard shortcuts via vim-style chord overlay. Stash/restore, undo/redo, clipboard, thinking toggle.
47
47
 
48
- **[Command Enchantment](./packages/autocomplete/README.md)** — Enhanced `/unipi:*` autocomplete with package grouping, descriptions, colors, and registry audits that catch stale command docs before release.
48
+ **[Command Enchantment](./packages/autocomplete/README.md)** — Enhanced `/unipi:*` autocomplete with full command names, package tags, descriptions, colors, and registry audits that catch stale command docs before release.
49
49
 
50
50
  ## Architecture
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@ Command Enchantment has no user commands. It improves the editor autocomplete ex
9
9
  ## What It Does
10
10
 
11
11
  - Groups `/unipi:*` commands by package so workflow, memory, web, footer, and other commands are visually distinct.
12
+ - Shows full command values like `unipi:brainstorm` while replacing Pi source tags with concise package tags like `[workflow]`.
12
13
  - Sorts matches in predictable tiers: exact Unipi matches first, then other Unipi matches, then system commands.
13
14
  - Preserves dynamic argument completions from command providers, including workflow document and worktree suggestions.
14
15
  - Ships an audit test that checks registered Unipi commands are represented in the autocomplete registry and have descriptions.
@@ -43,6 +43,18 @@ function fuzzyMatch(text: string, query: string): boolean {
43
43
 
44
44
  // ─── Namespace detection ─────────────────────────────────────────────
45
45
 
46
+ /**
47
+ * Pi prefixes extension command descriptions with source tags such as
48
+ * `[u:npm:@pi-unipi/unipi]`. The enchanted provider replaces that with
49
+ * package tags (`[workflow]`, `[memory]`, …), so strip the source tag from
50
+ * base descriptions before reusing them.
51
+ */
52
+ function stripPiSourceTag(description: string): string {
53
+ return description
54
+ .replace(/^\[(?:[upt])(?::[^\]]+)?\]\s*/, "")
55
+ .trimStart();
56
+ }
57
+
46
58
  /**
47
59
  * If the query looks like a package namespace (e.g. "workflow", "memory",
48
60
  * "utility"), return that package name so its commands sort to the top.
@@ -174,7 +186,7 @@ function getEnhancedUnipiItems(
174
186
 
175
187
  return {
176
188
  value: cmd,
177
- label: cmd.replace("unipi:", ""),
189
+ label: cmd,
178
190
  description: desc ? `${tag} ${desc}` : tag,
179
191
  };
180
192
  });
@@ -261,7 +273,10 @@ export function createEnchantedProvider(
261
273
  for (const item of baseSuggestions.items) {
262
274
  if (item.value.startsWith("unipi:")) {
263
275
  if (item.description) {
264
- descriptionOverrides.set(item.value, item.description);
276
+ const cleanDescription = stripPiSourceTag(item.description);
277
+ if (cleanDescription) {
278
+ descriptionOverrides.set(item.value, cleanDescription);
279
+ }
265
280
  }
266
281
  } else {
267
282
  nonUnipiItems.push(item);
@@ -69,6 +69,7 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
69
69
  "preset": "default",
70
70
  "separator": "powerline-thin",
71
71
  "iconStyle": "nerd",
72
+ "colorMode": "auto",
72
73
  "groups": {
73
74
  "compactor": {
74
75
  "show": true,
@@ -105,6 +106,17 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
105
106
 
106
107
  When `iconStyle` is not set, footer auto-detects Nerd Font support and defaults to `nerd` if available, `emoji` otherwise.
107
108
 
109
+ ### Color Mode
110
+
111
+ | Mode | Description |
112
+ |------|-------------|
113
+ | `auto` | Detect terminal support from environment (default) |
114
+ | `truecolor` | Force 24-bit ANSI color |
115
+ | `256` | Force xterm-256 color fallback |
116
+ | `none` | Disable footer color escapes |
117
+
118
+ `auto` uses truecolor where supported and downgrades to xterm-256 colors for terminals such as Apple Terminal that do not reliably render 24-bit color escapes.
119
+
108
120
  ### Responsive Layout
109
121
 
110
122
  ```
@@ -8,7 +8,7 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import * as os from "node:os";
11
- import type { FooterSettings, FooterGroupSettings, SeparatorStyle, IconStyle } from "./types.js";
11
+ import type { FooterSettings, FooterGroupSettings, SeparatorStyle, IconStyle, ColorMode } from "./types.js";
12
12
  import { UNIPI_SETTINGS_KEY } from "@pi-unipi/core";
13
13
 
14
14
  /** Default footer settings */
@@ -19,6 +19,7 @@ export const DEFAULT_FOOTER_SETTINGS: FooterSettings = {
19
19
  iconStyle: "nerd",
20
20
  zoneSeparator: "\u2502", // │
21
21
  showFullLabels: false,
22
+ colorMode: "auto",
22
23
  groups: {
23
24
  core: { show: true, segments: {} },
24
25
  compactor: { show: true, segments: {} },
@@ -96,6 +97,7 @@ export function loadFooterSettings(): FooterSettings {
96
97
  iconStyle: isValidIconStyle(footer.iconStyle) ? footer.iconStyle as IconStyle : DEFAULT_FOOTER_SETTINGS.iconStyle,
97
98
  zoneSeparator: typeof footer.zoneSeparator === "string" ? footer.zoneSeparator : DEFAULT_FOOTER_SETTINGS.zoneSeparator,
98
99
  showFullLabels: typeof footer.showFullLabels === "boolean" ? footer.showFullLabels : DEFAULT_FOOTER_SETTINGS.showFullLabels,
100
+ colorMode: isValidColorMode(footer.colorMode) ? footer.colorMode as ColorMode : DEFAULT_FOOTER_SETTINGS.colorMode,
99
101
  groups: mergeGroupSettings(
100
102
  DEFAULT_FOOTER_SETTINGS.groups,
101
103
  footer.groups as Record<string, FooterGroupSettings> | undefined,
@@ -170,6 +172,12 @@ function isValidIconStyle(value: unknown): boolean {
170
172
  return valid.includes(value);
171
173
  }
172
174
 
175
+ function isValidColorMode(value: unknown): boolean {
176
+ if (typeof value !== "string") return false;
177
+ const valid: string[] = ["auto", "truecolor", "256", "none"];
178
+ return valid.includes(value);
179
+ }
180
+
173
181
  function mergeGroupSettings(
174
182
  defaults: Record<string, FooterGroupSettings>,
175
183
  overrides: Record<string, FooterGroupSettings> | undefined,
@@ -11,7 +11,7 @@ import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, Rende
11
11
  import type { FooterRegistry } from "../registry/index.js";
12
12
  import { visibleWidth as piVisibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
13
13
  import { getSeparator, separatorVisibleWidth } from "./separators.js";
14
- import { getDefaultColors } from "./theme.js";
14
+ import { getDefaultColors, setColorMode, refreshColorMode } from "./theme.js";
15
15
  import { setIconStyle } from "./icons.js";
16
16
  import { getPreset } from "../presets.js";
17
17
  import { isSegmentEnabled, isSegmentExplicitlyEnabled, loadFooterSettings } from "../config.js";
@@ -104,6 +104,19 @@ export class FooterRenderer {
104
104
  private syncIconStyle(): void {
105
105
  const settings = loadFooterSettings();
106
106
  setIconStyle(settings.iconStyle);
107
+ this.syncColorMode(settings);
108
+ }
109
+
110
+ /** Sync the colour-emission mode from settings to the theme module. */
111
+ private syncColorMode(settings?: ReturnType<typeof loadFooterSettings>): void {
112
+ const s = settings ?? loadFooterSettings();
113
+ const mode = s.colorMode ?? "auto";
114
+ if (mode === "auto") {
115
+ setColorMode(null);
116
+ refreshColorMode();
117
+ } else {
118
+ setColorMode(mode);
119
+ }
107
120
  }
108
121
 
109
122
  /** Get the active preset name */
@@ -3,11 +3,174 @@
3
3
  *
4
4
  * Maps semantic color names to pi theme colors. Supports
5
5
  * hex overrides via ColorScheme.
6
+ *
7
+ * Color emission is capability-aware: hex values are emitted as 24-bit
8
+ * truecolor on terminals that advertise truecolor (COLORTERM or known
9
+ * truecolor TERM_PROGRAM), and gracefully downgraded to the nearest
10
+ * xterm-256 index on legacy terminals (notably Apple Terminal.app, which
11
+ * silently swallows 24-bit escapes and renders the text uncoloured).
6
12
  */
7
13
 
8
14
  import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
9
15
  import type { ColorScheme, ColorValue, SemanticColor, ThemeLike } from "../types.js";
10
16
 
17
+ // ─── Color mode detection ──────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Detected colour-emission mode for the current terminal.
21
+ * - `truecolor` → emit 24-bit `\x1b[38;2;R;G;Bm`
22
+ * - `256` → emit 8-bit `\x1b[38;5;Nm` (nearest xterm-256 index)
23
+ * - `none` → emit no colour codes (plain text)
24
+ */
25
+ export type ColorMode = "truecolor" | "256" | "none";
26
+
27
+ /** Manual override (set via `setColorMode`) — wins over env-based detection. */
28
+ let manualMode: ColorMode | null = null;
29
+ /** Cached detection result; cleared by `refreshColorMode()`. */
30
+ let detectedMode: ColorMode | null = null;
31
+
32
+ /** Terminals known to honour 24-bit truecolor escapes. */
33
+ const TRUECOLOR_TERM_PROGRAMS = [
34
+ "iTerm.app",
35
+ "WezTerm",
36
+ "Alacritty",
37
+ "vscode",
38
+ "Hyper",
39
+ "Warp",
40
+ "ghostty",
41
+ "Ghostty",
42
+ "zed",
43
+ "Zed",
44
+ "cursor",
45
+ "Cursor",
46
+ ];
47
+
48
+ /**
49
+ * Detect the colour mode of the current terminal from environment.
50
+ * Respects `NO_COLOR`, `FORCE_COLOR`, then sniffs `COLORTERM` / `TERM_PROGRAM`
51
+ * / `TERM`. Apple Terminal is explicitly capped at 256-colour.
52
+ */
53
+ function detectColorMode(): ColorMode {
54
+ const env = process.env;
55
+ if (env.NO_COLOR || env.NODE_DISABLE_COLORS) return "none";
56
+ if (env.FORCE_COLOR) {
57
+ const lvl = parseInt(env.FORCE_COLOR, 10);
58
+ if (Number.isNaN(lvl)) return "truecolor"; // any non-numeric truthy value
59
+ if (lvl >= 3) return "truecolor";
60
+ if (lvl >= 1) return "256";
61
+ return "none";
62
+ }
63
+ const term = env.TERM ?? "";
64
+ const termProgram = env.TERM_PROGRAM ?? "";
65
+ // Apple Terminal.app does NOT support 24-bit colour. Force 256 even if
66
+ // some wrapper leaked COLORTERM into the env.
67
+ if (termProgram === "Apple_Terminal") {
68
+ return "256";
69
+ }
70
+ if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") {
71
+ return "truecolor";
72
+ }
73
+ if (TRUECOLOR_TERM_PROGRAMS.includes(termProgram)) {
74
+ return "truecolor";
75
+ }
76
+ if (term === "dumb") return "none";
77
+ // Anything that talks ANSI but isn't known-truecolor → 256
78
+ if (term.includes("256color") || term.includes("color") || term.includes("xterm") || term.includes("ansi") || term.includes("screen") || term.includes("tmux")) {
79
+ return "256";
80
+ }
81
+ // Default conservative: 256 colour. Better than dropping colours entirely
82
+ // on unknown terminals that almost certainly understand the 256 palette.
83
+ return "256";
84
+ }
85
+
86
+ /**
87
+ * Get the active colour mode (manual override > cached detection > env probe).
88
+ */
89
+ export function getColorMode(): ColorMode {
90
+ if (manualMode) return manualMode;
91
+ if (!detectedMode) detectedMode = detectColorMode();
92
+ return detectedMode;
93
+ }
94
+
95
+ /**
96
+ * Force a specific colour mode (e.g. from user settings). Pass `null` to
97
+ * revert to auto-detection.
98
+ */
99
+ export function setColorMode(mode: ColorMode | null): void {
100
+ manualMode = mode;
101
+ }
102
+
103
+ /** Re-run env-based detection (useful after settings or env changes). */
104
+ export function refreshColorMode(): ColorMode {
105
+ detectedMode = null;
106
+ return getColorMode();
107
+ }
108
+
109
+ // ─── 24-bit → 256 colour downgrade ─────────────────────────────────────────
110
+
111
+ /**
112
+ * Map an RGB triple to the nearest xterm-256 index.
113
+ *
114
+ * xterm-256 layout:
115
+ * 0–15 → system / bright colours (skipped; we route through the cube
116
+ * for predictability)
117
+ * 16–231 → 6×6×6 colour cube
118
+ * 232–255 → 24-step grayscale
119
+ *
120
+ * We pick the better of (cube nearest) and (grayscale nearest) by squared
121
+ * RGB distance, which matches the heuristic used by chalk / ansi-styles.
122
+ */
123
+ function rgbTo256(r: number, g: number, b: number): number {
124
+ // Grayscale candidate
125
+ const grayAvg = Math.round((r + g + b) / 3);
126
+ let grayIdx: number;
127
+ let grayR: number;
128
+ if (grayAvg < 8) {
129
+ grayIdx = 16;
130
+ grayR = 0;
131
+ } else if (grayAvg > 248) {
132
+ grayIdx = 231;
133
+ grayR = 255;
134
+ } else {
135
+ const step = Math.round(((grayAvg - 8) / 247) * 24);
136
+ grayIdx = 232 + step;
137
+ grayR = 8 + step * 10;
138
+ }
139
+ const grayDist = sqDist(r, g, b, grayR, grayR, grayR);
140
+
141
+ // Cube candidate (6 steps: 0, 95, 135, 175, 215, 255)
142
+ const cubeR = cubeStep(r);
143
+ const cubeG = cubeStep(g);
144
+ const cubeB = cubeStep(b);
145
+ const cubeIdx = 16 + 36 * cubeR.idx + 6 * cubeG.idx + cubeB.idx;
146
+ const cubeDist = sqDist(r, g, b, cubeR.value, cubeG.value, cubeB.value);
147
+
148
+ return cubeDist <= grayDist ? cubeIdx : grayIdx;
149
+ }
150
+
151
+ const CUBE_STEPS = [0, 95, 135, 175, 215, 255];
152
+ function cubeStep(v: number): { idx: number; value: number } {
153
+ let bestIdx = 0;
154
+ let bestDist = Infinity;
155
+ for (let i = 0; i < CUBE_STEPS.length; i++) {
156
+ const d = Math.abs(v - CUBE_STEPS[i]);
157
+ if (d < bestDist) {
158
+ bestDist = d;
159
+ bestIdx = i;
160
+ }
161
+ }
162
+ return { idx: bestIdx, value: CUBE_STEPS[bestIdx] };
163
+ }
164
+
165
+ function sqDist(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
166
+ const dr = r1 - r2;
167
+ const dg = g1 - g2;
168
+ const db = b1 - b2;
169
+ return dr * dr + dg * dg + db * db;
170
+ }
171
+
172
+ // ─── Public API ────────────────────────────────────────────────────────────
173
+
11
174
  /** Wrap text in dim ANSI codes for muted placeholder display */
12
175
  export function mutedPlaceholder(text: string): string {
13
176
  return `\x1b[2m${text}\x1b[0m`;
@@ -95,6 +258,26 @@ export function resolveColor(color: ColorValue, theme: ThemeLike): string {
95
258
  return theme.fg(color as ThemeColor, "").replace(/\x1b\[0m$/, "");
96
259
  }
97
260
 
261
+ /**
262
+ * Wrap `text` in an ANSI escape for the given hex value, chosen to match
263
+ * the active colour mode (truecolor / 256 / none).
264
+ */
265
+ function wrapHex(hex: string, text: string): string {
266
+ const mode = getColorMode();
267
+ if (mode === "none") return text;
268
+ const h = hex.startsWith("#") ? hex.slice(1) : hex;
269
+ const r = parseInt(h.slice(0, 2), 16);
270
+ const g = parseInt(h.slice(2, 4), 16);
271
+ const b = parseInt(h.slice(4, 6), 16);
272
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return text;
273
+ if (mode === "truecolor") {
274
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
275
+ }
276
+ // 256-color fallback
277
+ const idx = rgbTo256(r, g, b);
278
+ return `\x1b[38;5;${idx}m${text}\x1b[0m`;
279
+ }
280
+
98
281
  /**
99
282
  * Apply a semantic color to text using the theme.
100
283
  * Falls back to the default theme color if no override is provided.
@@ -109,16 +292,14 @@ export function applyColor(
109
292
  if (!colorValue) {
110
293
  // Use default from the map
111
294
  const defaultColor = DEFAULT_COLOR_MAP[semantic] || "text";
295
+ if (typeof defaultColor === "string" && defaultColor.startsWith("#")) {
296
+ return wrapHex(defaultColor, text);
297
+ }
112
298
  return theme.fg(defaultColor as ThemeColor, text);
113
299
  }
114
300
 
115
301
  if (colorValue.startsWith("#")) {
116
- // Hex color — we need to emit ANSI directly
117
- const hex = colorValue.slice(1);
118
- const r = Number.parseInt(hex.slice(0, 2), 16);
119
- const g = Number.parseInt(hex.slice(2, 4), 16);
120
- const b = Number.parseInt(hex.slice(4, 6), 16);
121
- return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
302
+ return wrapHex(colorValue, text);
122
303
  }
123
304
 
124
305
  return theme.fg(colorValue as ThemeColor, text);
@@ -82,6 +82,9 @@ export type ColorScheme = Partial<Record<SemanticColor, ColorValue>>;
82
82
  /** Icon style — determines which icon set is used for segments */
83
83
  export type IconStyle = "nerd" | "emoji" | "text";
84
84
 
85
+ /** Colour-emission mode for terminal output. */
86
+ export type ColorMode = "auto" | "truecolor" | "256" | "none";
87
+
85
88
  /** Separator styles for segment dividers */
86
89
  export type SeparatorStyle =
87
90
  | "powerline"
@@ -190,6 +193,8 @@ export interface FooterSettings {
190
193
  zoneSeparator?: string;
191
194
  /** Show full labels instead of compact short labels */
192
195
  showFullLabels?: boolean;
196
+ /** Terminal colour mode override (default: "auto" — env-detected). */
197
+ colorMode?: ColorMode;
193
198
  /** Per-group settings */
194
199
  groups: Record<string, FooterGroupSettings>;
195
200
  }
@@ -8,6 +8,7 @@
8
8
  import { getInstalledPackageVersion } from "@pi-unipi/core";
9
9
  import { loadConfig } from "./settings.js";
10
10
  import { readLastCheck, writeLastCheck, isCheckDue } from "./cache.js";
11
+ import { compareVersions, isNewerVersion } from "./version.js";
11
12
  import type { UpdateCheckResult } from "../types.js";
12
13
 
13
14
  /** NPM registry URL for the unipi umbrella package */
@@ -20,6 +21,16 @@ function getInstalledVersion(): string {
20
21
  return getInstalledPackageVersion(dir, "@pi-unipi/unipi");
21
22
  }
22
23
 
24
+
25
+ /** Build an update result without ever reporting downgrades as updates. */
26
+ function toUpdateResult(latestVersion: string, currentVersion: string): UpdateCheckResult {
27
+ return {
28
+ updateAvailable: isNewerVersion(latestVersion, currentVersion),
29
+ latestVersion,
30
+ currentVersion,
31
+ };
32
+ }
33
+
23
34
  /**
24
35
  * Check for updates from npm registry.
25
36
  * Respects check interval — skips if last check was recent.
@@ -31,15 +42,14 @@ export async function checkForUpdates(): Promise<UpdateCheckResult> {
31
42
  try {
32
43
  const config = loadConfig();
33
44
 
34
- // Check if we need to fetch (interval not elapsed)
35
- if (!isCheckDue(config.checkIntervalMs)) {
36
- const cache = readLastCheck();
37
- if (cache) {
38
- return {
39
- updateAvailable: cache.latestVersion !== currentVersion,
40
- latestVersion: cache.latestVersion,
41
- currentVersion,
42
- };
45
+ // Check if we need to fetch (interval not elapsed). If the cached npm
46
+ // version is older than the installed version, ignore the interval and
47
+ // refresh: this happens immediately after a local/source release before
48
+ // the updater cache has seen the new npm dist-tag.
49
+ const cache = readLastCheck();
50
+ if (cache && !isCheckDue(config.checkIntervalMs)) {
51
+ if (compareVersions(cache.latestVersion, currentVersion) >= 0) {
52
+ return toUpdateResult(cache.latestVersion, currentVersion);
43
53
  }
44
54
  }
45
55
 
@@ -66,16 +76,13 @@ export async function checkForUpdates(): Promise<UpdateCheckResult> {
66
76
  latestVersion,
67
77
  });
68
78
 
69
- return {
70
- updateAvailable: latestVersion !== currentVersion,
71
- latestVersion,
72
- currentVersion,
73
- };
79
+ return toUpdateResult(latestVersion, currentVersion);
74
80
  } catch (err: unknown) {
75
- // Network error — return cached info if available
81
+ // Network error — return cached info if available, but never suggest a
82
+ // downgrade from a stale cache.
76
83
  const cache = readLastCheck();
77
84
  return {
78
- updateAvailable: false,
85
+ updateAvailable: cache ? isNewerVersion(cache.latestVersion, currentVersion) : false,
79
86
  latestVersion: cache?.latestVersion ?? "",
80
87
  currentVersion,
81
88
  error: err instanceof Error ? err.message : String(err) || "Unknown error",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @pi-unipi/updater — version comparison helpers
3
+ */
4
+
5
+ /**
6
+ * Compare two semver-ish version strings.
7
+ * Returns 1 when a > b, -1 when a < b, 0 when equal.
8
+ *
9
+ * This intentionally handles the simple versions Unipi publishes (x.y.z)
10
+ * without adding a runtime dependency. Non-numeric suffixes are ignored for
11
+ * ordering, so `2.0.5` and `v2.0.5` compare equal.
12
+ */
13
+ export function compareVersions(a: string, b: string): number {
14
+ const parse = (version: string): number[] => version
15
+ .replace(/^v/, "")
16
+ .split(/[.-]/)
17
+ .slice(0, 3)
18
+ .map((part) => {
19
+ const parsed = Number.parseInt(part, 10);
20
+ return Number.isNaN(parsed) ? 0 : parsed;
21
+ });
22
+
23
+ const left = parse(a);
24
+ const right = parse(b);
25
+ for (let i = 0; i < 3; i++) {
26
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
27
+ if (diff > 0) return 1;
28
+ if (diff < 0) return -1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ /** Return true only when `latest` is newer than `current`. */
34
+ export function isNewerVersion(latest: string, current: string): boolean {
35
+ return compareVersions(latest, current) > 0;
36
+ }
@@ -128,9 +128,12 @@ import { detectCapabilities, getIcon } from "@pi-unipi/utility/display/capabilit
128
128
 
129
129
  const caps = detectCapabilities();
130
130
  console.log("Nerd Font:", caps.nerdFont);
131
+ console.log("Truecolor:", caps.truecolor);
131
132
  console.log(getIcon("󰘳", "[OK]")); // Uses Nerd Font if available
132
133
  ```
133
134
 
135
+ Capability detection distinguishes basic color, xterm-256-compatible terminals, and truecolor-capable terminals. Apple Terminal is treated as color-capable but not truecolor-capable so renderers can fall back safely.
136
+
134
137
  ## Privacy
135
138
 
136
139
  The analytics collector is privacy-respecting:
@@ -42,24 +42,38 @@ function detectColorSupport(): { color: boolean; truecolor: boolean } {
42
42
  const term = env.TERM || "";
43
43
  const termProgram = env.TERM_PROGRAM || "";
44
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",
45
+ // Apple Terminal.app does NOT support 24-bit truecolor (longstanding
46
+ // limitation as of macOS 26). Force 256-colour even if some wrapper
47
+ // leaked COLORTERM=truecolor into the environment.
48
+ const isAppleTerminal = termProgram === "Apple_Terminal";
49
+
50
+ // Truecolor TERM_PROGRAM allow-list. Note: terminfo names like
51
+ // "xterm-256color" / "screen-256color" / "tmux-256color" advertise
52
+ // 256-colour, NOT truecolor — they must not appear here.
53
+ const truecolorTermPrograms = [
54
+ "iTerm.app",
55
+ "WezTerm",
56
+ "Alacritty",
57
+ "vscode",
58
+ "Hyper",
59
+ "Warp",
56
60
  "ghostty",
61
+ "Ghostty",
62
+ "zed",
63
+ "Zed",
64
+ "cursor",
65
+ "Cursor",
57
66
  ];
58
67
 
59
- const hasTruecolor =
60
- env.COLORTERM === "truecolor" ||
61
- env.COLORTERM === "24bit" ||
62
- truecolorTerms.some((t) => term.includes(t) || termProgram.includes(t));
68
+ // Truecolor TERM tokens (the rare TERM that *does* advertise truecolor).
69
+ const truecolorTermTokens = ["truecolor", "24bit", "alacritty", "kitty", "wezterm"];
70
+
71
+ const hasTruecolor = isAppleTerminal
72
+ ? false
73
+ : env.COLORTERM === "truecolor" ||
74
+ env.COLORTERM === "24bit" ||
75
+ truecolorTermPrograms.includes(termProgram) ||
76
+ truecolorTermTokens.some((t) => term.includes(t));
63
77
 
64
78
  // Basic color support
65
79
  const hasColor =