Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions
Pure formatting / lint-fix pass that ran during `npm run build:core` in the session that landed the agent-runner / quota / coverage / phase-2 routing work. No logic changes — indentation, trailing commas, import sort, etc. Captured separately so the actual feature commits stay scoped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
8.2 KiB
TypeScript
287 lines
8.2 KiB
TypeScript
/**
|
|
* resource-display.ts — utility functions for formatting loaded-resource paths and diagnostics.
|
|
*
|
|
* Purpose: isolate the pure display-formatting logic that was previously embedded as private
|
|
* methods in InteractiveMode. These functions have no side-effects and take all their inputs
|
|
* as parameters so they can be unit-tested and reused without a class instance.
|
|
*
|
|
* Consumer: interactive-mode.ts (showLoadedResources, formatDiagnostics callsites).
|
|
*/
|
|
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
|
|
import { theme } from "./theme/theme.js";
|
|
|
|
/** Replace the home directory prefix with `~` for compact display. */
|
|
export function formatDisplayPath(p: string): string {
|
|
const home = os.homedir();
|
|
let result = p;
|
|
if (result.startsWith(home)) {
|
|
result = `~${result.slice(home.length)}`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get a short path relative to the package root for display.
|
|
* npm packages show a path relative to node_modules/pkg/; git packages relative to repo root;
|
|
* local paths fall back to formatDisplayPath.
|
|
*/
|
|
export function getShortPath(fullPath: string, source: string): string {
|
|
const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
|
|
if (npmMatch && source.startsWith("npm:")) {
|
|
return npmMatch[2];
|
|
}
|
|
|
|
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
|
|
if (gitMatch && source.startsWith("git:")) {
|
|
return gitMatch[1];
|
|
}
|
|
|
|
return formatDisplayPath(fullPath);
|
|
}
|
|
|
|
/** Map a resource source + scope to a human-readable label and accent color. */
|
|
export function getDisplaySourceInfo(
|
|
source: string,
|
|
scope: string,
|
|
): { label: string; scopeLabel?: string; color: "accent" | "muted" } {
|
|
if (source === "local") {
|
|
if (scope === "user") {
|
|
return { label: "user", color: "muted" };
|
|
}
|
|
if (scope === "project") {
|
|
return { label: "project", color: "muted" };
|
|
}
|
|
if (scope === "temporary") {
|
|
return { label: "path", scopeLabel: "temp", color: "muted" };
|
|
}
|
|
return { label: "path", color: "muted" };
|
|
}
|
|
|
|
if (source === "cli") {
|
|
return {
|
|
label: "path",
|
|
scopeLabel: scope === "temporary" ? "temp" : undefined,
|
|
color: "muted",
|
|
};
|
|
}
|
|
|
|
const scopeLabel =
|
|
scope === "user"
|
|
? "user"
|
|
: scope === "project"
|
|
? "project"
|
|
: scope === "temporary"
|
|
? "temp"
|
|
: undefined;
|
|
return { label: source, scopeLabel, color: "accent" };
|
|
}
|
|
|
|
/** Classify a resource source+scope into one of the three display groups. */
|
|
export function getScopeGroup(
|
|
source: string,
|
|
scope: string,
|
|
): "user" | "project" | "path" {
|
|
if (source === "cli" || scope === "temporary") return "path";
|
|
if (scope === "user") return "user";
|
|
if (scope === "project") return "project";
|
|
return "path";
|
|
}
|
|
|
|
/** True when the source is an npm or git package (i.e. managed by the package manager). */
|
|
export function isPackageSource(source: string): boolean {
|
|
return source.startsWith("npm:") || source.startsWith("git:");
|
|
}
|
|
|
|
/**
|
|
* Group a list of resource paths by their user/project/path scope bucket.
|
|
* Within each bucket, package-manager sources are further grouped by package.
|
|
*/
|
|
export function buildScopeGroups(
|
|
paths: string[],
|
|
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
|
): Array<{
|
|
scope: "user" | "project" | "path";
|
|
paths: string[];
|
|
packages: Map<string, string[]>;
|
|
}> {
|
|
const groups: Record<
|
|
"user" | "project" | "path",
|
|
{
|
|
scope: "user" | "project" | "path";
|
|
paths: string[];
|
|
packages: Map<string, string[]>;
|
|
}
|
|
> = {
|
|
user: { scope: "user", paths: [], packages: new Map() },
|
|
project: { scope: "project", paths: [], packages: new Map() },
|
|
path: { scope: "path", paths: [], packages: new Map() },
|
|
};
|
|
|
|
for (const p of paths) {
|
|
const meta = findMetadata(p, metadata);
|
|
const source = meta?.source ?? "local";
|
|
const scope = meta?.scope ?? "project";
|
|
const groupKey = getScopeGroup(source, scope);
|
|
const group = groups[groupKey];
|
|
|
|
if (isPackageSource(source)) {
|
|
const list = group.packages.get(source) ?? [];
|
|
list.push(p);
|
|
group.packages.set(source, list);
|
|
} else {
|
|
group.paths.push(p);
|
|
}
|
|
}
|
|
|
|
return [groups.project, groups.user, groups.path].filter(
|
|
(group) => group.paths.length > 0 || group.packages.size > 0,
|
|
);
|
|
}
|
|
|
|
/** Render scope groups into a multi-line indented string for display. */
|
|
export function formatScopeGroups(
|
|
groups: Array<{
|
|
scope: "user" | "project" | "path";
|
|
paths: string[];
|
|
packages: Map<string, string[]>;
|
|
}>,
|
|
options: {
|
|
formatPath: (p: string) => string;
|
|
formatPackagePath: (p: string, source: string) => string;
|
|
},
|
|
): string {
|
|
const lines: string[] = [];
|
|
|
|
for (const group of groups) {
|
|
lines.push(` ${theme.fg("accent", group.scope)}`);
|
|
|
|
const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b));
|
|
for (const p of sortedPaths) {
|
|
lines.push(theme.fg("dim", ` ${options.formatPath(p)}`));
|
|
}
|
|
|
|
const sortedPackages = Array.from(group.packages.entries()).sort(
|
|
([a], [b]) => a.localeCompare(b),
|
|
);
|
|
for (const [source, paths] of sortedPackages) {
|
|
lines.push(` ${theme.fg("mdLink", source)}`);
|
|
const sortedPackagePaths = [...paths].sort((a, b) => a.localeCompare(b));
|
|
for (const p of sortedPackagePaths) {
|
|
lines.push(
|
|
theme.fg("dim", ` ${options.formatPackagePath(p, source)}`),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Find metadata for a path, checking parent directories if exact match fails.
|
|
* The package manager stores metadata for directories, but we display file paths.
|
|
*/
|
|
export function findMetadata(
|
|
p: string,
|
|
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
|
): { source: string; scope: string; origin: string } | undefined {
|
|
const exact = metadata.get(p);
|
|
if (exact) return exact;
|
|
|
|
let current = p;
|
|
let parent = path.dirname(current);
|
|
while (parent !== current) {
|
|
const meta = metadata.get(parent);
|
|
if (meta) return meta;
|
|
current = parent;
|
|
parent = path.dirname(current);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/** Format a path with its source/scope info derived from loader metadata. */
|
|
export function formatPathWithSource(
|
|
p: string,
|
|
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
|
): string {
|
|
const meta = findMetadata(p, metadata);
|
|
if (meta) {
|
|
const shortPath = getShortPath(p, meta.source);
|
|
const { label, scopeLabel } = getDisplaySourceInfo(meta.source, meta.scope);
|
|
const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
|
|
return `${labelText} ${shortPath}`;
|
|
}
|
|
return formatDisplayPath(p);
|
|
}
|
|
|
|
/**
|
|
* Format resource diagnostics with nice collision display using metadata.
|
|
*
|
|
* Purpose: render skill/prompt/extension load errors and name collisions into
|
|
* a human-readable multi-line string so showLoadedResources can add them to chat.
|
|
*
|
|
* Consumer: interactive-mode.ts showLoadedResources.
|
|
*/
|
|
export function formatDiagnostics(
|
|
diagnostics: readonly ResourceDiagnostic[],
|
|
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
|
): string {
|
|
const lines: string[] = [];
|
|
|
|
const collisions = new Map<string, ResourceDiagnostic[]>();
|
|
const otherDiagnostics: ResourceDiagnostic[] = [];
|
|
|
|
for (const d of diagnostics) {
|
|
if (d.type === "collision" && d.collision) {
|
|
const list = collisions.get(d.collision.name) ?? [];
|
|
list.push(d);
|
|
collisions.set(d.collision.name, list);
|
|
} else {
|
|
otherDiagnostics.push(d);
|
|
}
|
|
}
|
|
|
|
for (const [name, collisionList] of collisions) {
|
|
const first = collisionList[0]?.collision;
|
|
if (!first) continue;
|
|
lines.push(theme.fg("warning", ` "${name}" collision:`));
|
|
lines.push(
|
|
theme.fg(
|
|
"dim",
|
|
` ${theme.fg("success", "✓")} ${formatPathWithSource(first.winnerPath, metadata)}`,
|
|
),
|
|
);
|
|
for (const d of collisionList) {
|
|
if (d.collision) {
|
|
lines.push(
|
|
theme.fg(
|
|
"dim",
|
|
` ${theme.fg("warning", "✗")} ${formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const d of otherDiagnostics) {
|
|
if (d.path) {
|
|
const sourceInfo = formatPathWithSource(d.path, metadata);
|
|
lines.push(
|
|
theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`),
|
|
);
|
|
lines.push(
|
|
theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`),
|
|
);
|
|
} else {
|
|
lines.push(
|
|
theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|