Include shared/ dir in repo (gitignore allowlist)

The previous commit referenced shared/ansi.ts, shared/format.ts, and
shared/ctx.ts but those files were filtered by the default-deny
.gitignore. Adding !/shared/ to the allowlist so the imports actually
resolve in a fresh clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 00:05:38 +02:00
parent f1ceeb4363
commit 31300cc2a2
4 changed files with 120 additions and 0 deletions
+1
View File
@@ -18,5 +18,6 @@
!/mechanicus-status-line.ts
!/mechanicus-thinking-label.ts
!/scripts/
!/shared/
!/tests/
!/themes/
+47
View File
@@ -0,0 +1,47 @@
/**
* ANSI escape-sequence helpers shared across extensions.
*
* Kept free of relative imports so Node's --experimental-strip-types can load
* this directly in tests.
*/
export const ANSI_RESET_FG = "\x1b[39m";
export const ANSI_RESET_ALL = "\x1b[0m";
/**
* Convert `#rrggbb` (or `#rgb`) to [r, g, b] bytes. Throws on malformed input
* rather than silently producing NaN — previously the callers built broken
* ANSI codes out of `\x1b[38;2;NaN;NaN;NaNm` which some terminals render as
* bright cyan, giving mysterious color bugs.
*/
export function hexToRgb(hex: string): [number, number, number] {
const m = hex.trim().replace(/^#/, "");
const expanded =
m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
throw new Error(`Invalid hex color: ${hex}`);
}
const n = parseInt(expanded, 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
/** Build a 24-bit foreground ANSI escape for an `#rrggbb` hex color. */
export function fgFromHex(hex: string): string {
const [r, g, b] = hexToRgb(hex);
return `\x1b[38;2;${r};${g};${b}m`;
}
/**
* Strip every ANSI CSI escape sequence from a string. Useful when the
* renderer needs to know the visible width of a line that contains color
* codes.
*/
export function stripAnsi(s: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: CSI stripping is the point
return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
}
/** Count visible grapheme-less characters. Mirrors pi-tui's visibleWidth. */
export function visibleWidth(s: string): number {
return stripAnsi(s).length;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Helpers for dealing with pi's ExtensionContext in the face of its
* "stale after session replacement or reload" semantics.
*
* Pi's ExtensionRunner throws when any captured ctx field is accessed after
* the runner has been torn down (on /quit, /reload, new session). This
* module provides a single guarded wrapper so every extension doesn't have
* to re-implement try/catch + null-on-shutdown patterns.
*/
/**
* Run `fn` with the given ctx, swallowing any "stale context" errors that
* occur when pi's runner has gone away between the event that captured ctx
* and the time we're now using it (TUI render timers, setInterval ticks,
* etc.). Returns `undefined` on failure.
*/
export function safely<T>(fn: () => T): T | undefined {
try {
return fn();
} catch {
return undefined;
}
}
/**
* Async variant of `safely`.
*/
export async function safelyAsync<T>(fn: () => Promise<T>): Promise<T | undefined> {
try {
return await fn();
} catch {
return undefined;
}
}
/** Minimal shape we rely on — avoids coupling every caller to ExtensionContext. */
export interface UiLike {
hasUI: boolean;
}
export function hasUI(ctx: UiLike | null | undefined): ctx is UiLike & { hasUI: true } {
return !!ctx && ctx.hasUI === true;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Small formatting helpers. Pure, no side effects, no external imports.
*/
/**
* Format a token count as a compact human-readable string.
* formatTokens(42) = "42"
* formatTokens(1234) = "1.2k"
* formatTokens(12345) = "12k"
*/
export function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
return `${Math.round(n / 1000)}k`;
}
/**
* Format a millisecond duration for the working indicator / timers.
* formatElapsed(450) = "0s"
* formatElapsed(42_000) = "42s"
* formatElapsed(90_500) = "1m 30s"
*/
export function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}