singularity-forge/packages/coding-agent/src/modes/interactive/resource-display.ts
Mikael Hugo 365c6bbc3b
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
chore: formatter / linter touch-up (230 files)
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>
2026-05-16 21:19:53 +02:00

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");
}