feat(coding-agent): refine resource metadata and display

This commit is contained in:
Mario Zechner 2026-01-24 02:46:08 +01:00
parent 79ab767beb
commit 725d6bbf35
10 changed files with 213 additions and 109 deletions

View file

@ -10,6 +10,7 @@ import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mari
const loader1 = new DefaultResourceLoader({ const loader1 = new DefaultResourceLoader({
systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate. systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
Always end responses with "Arrr!"`, Always end responses with "Arrr!"`,
// Needed to avoid DefaultResourceLoader appending APPEND_SYSTEM.md from ~/.pi/agent or <cwd>/.pi.
appendSystemPromptOverride: () => [], appendSystemPromptOverride: () => [],
}); });
await loader1.reload(); await loader1.reload();

View file

@ -13,7 +13,7 @@ const customSkill: Skill = {
description: "Custom project instructions", description: "Custom project instructions",
filePath: "/virtual/SKILL.md", filePath: "/virtual/SKILL.md",
baseDir: "/virtual", baseDir: "/virtual",
source: "custom", source: "path",
}; };
const loader = new DefaultResourceLoader({ const loader = new DefaultResourceLoader({
@ -28,13 +28,13 @@ const loader = new DefaultResourceLoader({
await loader.reload(); await loader.reload();
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc. // Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const discovered = loader.getSkills(); const { skills: allSkills, diagnostics } = loader.getSkills();
console.log( console.log(
"Discovered skills:", "Discovered skills:",
discovered.skills.map((s) => s.name), allSkills.map((s) => s.name),
); );
if (discovered.diagnostics.length > 0) { if (diagnostics.length > 0) {
console.log("Warnings:", discovered.diagnostics); console.log("Warnings:", diagnostics);
} }
await createAgentSession({ await createAgentSession({

View file

@ -6,6 +6,7 @@
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Disable context files entirely by returning an empty list in agentsFilesOverride.
const loader = new DefaultResourceLoader({ const loader = new DefaultResourceLoader({
agentsFilesOverride: (current) => ({ agentsFilesOverride: (current) => ({
agentsFiles: [ agentsFiles: [

View file

@ -15,8 +15,8 @@ import {
const deployTemplate: PromptTemplate = { const deployTemplate: PromptTemplate = {
name: "deploy", name: "deploy",
description: "Deploy the application", description: "Deploy the application",
source: "(custom)", source: "path",
filePath: "<inline>", filePath: "/virtual/prompts/deploy.md",
content: `# Deploy Instructions content: `# Deploy Instructions
1. Build: npm run build 1. Build: npm run build

View file

@ -43,7 +43,7 @@ export interface PackageManager {
options?: { local?: boolean; temporary?: boolean }, options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>; ): Promise<ResolvedPaths>;
setProgressCallback(callback: ProgressCallback | undefined): void; setProgressCallback(callback: ProgressCallback | undefined): void;
getInstalledPath(source: string, scope: "global" | "project"): string | undefined; getInstalledPath(source: string, scope: "user" | "project"): string | undefined;
} }
interface PackageManagerOptions { interface PackageManagerOptions {
@ -52,7 +52,7 @@ interface PackageManagerOptions {
settingsManager: SettingsManager; settingsManager: SettingsManager;
} }
type SourceScope = "global" | "project" | "temporary"; type SourceScope = "user" | "project" | "temporary";
type NpmSource = { type NpmSource = {
type: "npm"; type: "npm";
@ -266,7 +266,7 @@ export class DefaultPackageManager implements PackageManager {
this.progressCallback = callback; this.progressCallback = callback;
} }
getInstalledPath(source: string, scope: "global" | "project"): string | undefined { getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
const parsed = this.parseSource(source); const parsed = this.parseSource(source);
if (parsed.type === "npm") { if (parsed.type === "npm") {
const path = this.getNpmInstallPath(parsed, scope); const path = this.getNpmInstallPath(parsed, scope);
@ -312,7 +312,7 @@ export class DefaultPackageManager implements PackageManager {
// Collect all packages with scope // Collect all packages with scope
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const pkg of globalSettings.packages ?? []) { for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "global" }); allPackages.push({ pkg, scope: "user" });
} }
for (const pkg of projectSettings.packages ?? []) { for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" }); allPackages.push({ pkg, scope: "project" });
@ -330,7 +330,7 @@ export class DefaultPackageManager implements PackageManager {
globalEntries, globalEntries,
resourceType, resourceType,
target, target,
{ source: "local", scope: "global", origin: "top-level" }, { source: "local", scope: "user", origin: "top-level" },
accumulator, accumulator,
); );
this.resolveLocalEntries( this.resolveLocalEntries(
@ -350,7 +350,7 @@ export class DefaultPackageManager implements PackageManager {
options?: { local?: boolean; temporary?: boolean }, options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths> { ): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator(); const accumulator = this.createAccumulator();
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global"; const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user";
const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope }));
await this.resolvePackageSources(packageSources, accumulator); await this.resolvePackageSources(packageSources, accumulator);
return this.toResolvedPaths(accumulator); return this.toResolvedPaths(accumulator);
@ -358,7 +358,7 @@ export class DefaultPackageManager implements PackageManager {
async install(source: string, options?: { local?: boolean }): Promise<void> { async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source); const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global"; const scope: SourceScope = options?.local ? "project" : "user";
await this.withProgress("install", source, `Installing ${source}...`, async () => { await this.withProgress("install", source, `Installing ${source}...`, async () => {
if (parsed.type === "npm") { if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false); await this.installNpm(parsed, scope, false);
@ -374,7 +374,7 @@ export class DefaultPackageManager implements PackageManager {
async remove(source: string, options?: { local?: boolean }): Promise<void> { async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source); const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global"; const scope: SourceScope = options?.local ? "project" : "user";
await this.withProgress("remove", source, `Removing ${source}...`, async () => { await this.withProgress("remove", source, `Removing ${source}...`, async () => {
if (parsed.type === "npm") { if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope); await this.uninstallNpm(parsed, scope);
@ -390,7 +390,7 @@ export class DefaultPackageManager implements PackageManager {
async update(source?: string): Promise<void> { async update(source?: string): Promise<void> {
if (source) { if (source) {
await this.updateSourceForScope(source, "global"); await this.updateSourceForScope(source, "user");
await this.updateSourceForScope(source, "project"); await this.updateSourceForScope(source, "project");
return; return;
} }
@ -398,7 +398,7 @@ export class DefaultPackageManager implements PackageManager {
const globalSettings = this.settingsManager.getGlobalSettings(); const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings(); const projectSettings = this.settingsManager.getProjectSettings();
for (const extension of globalSettings.extensions ?? []) { for (const extension of globalSettings.extensions ?? []) {
await this.updateSourceForScope(extension, "global"); await this.updateSourceForScope(extension, "user");
} }
for (const extension of projectSettings.extensions ?? []) { for (const extension of projectSettings.extensions ?? []) {
await this.updateSourceForScope(extension, "project"); await this.updateSourceForScope(extension, "project");
@ -575,8 +575,8 @@ export class DefaultPackageManager implements PackageManager {
const existing = seen.get(identity); const existing = seen.get(identity);
if (!existing) { if (!existing) {
seen.set(identity, entry); seen.set(identity, entry);
} else if (entry.scope === "project" && existing.scope === "global") { } else if (entry.scope === "project" && existing.scope === "user") {
// Project wins over global // Project wins over user
seen.set(identity, entry); seen.set(identity, entry);
} }
// If existing is project and new is global, keep existing (project) // If existing is project and new is global, keep existing (project)
@ -597,7 +597,7 @@ export class DefaultPackageManager implements PackageManager {
} }
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> { private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "global" && !temporary) { if (scope === "user" && !temporary) {
await this.runCommand("npm", ["install", "-g", source.spec]); await this.runCommand("npm", ["install", "-g", source.spec]);
return; return;
} }
@ -607,7 +607,7 @@ export class DefaultPackageManager implements PackageManager {
} }
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> { private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "global") { if (scope === "user") {
await this.runCommand("npm", ["uninstall", "-g", source.name]); await this.runCommand("npm", ["uninstall", "-g", source.name]);
return; return;
} }

View file

@ -11,7 +11,7 @@ export interface PromptTemplate {
name: string; name: string;
description: string; description: string;
content: string; content: string;
source: string; // e.g., "(user)", "(project)", "(custom:my-dir)" source: string; // e.g., "user", "project", "path", "inline"
filePath: string; // Absolute path to the template file filePath: string; // Absolute path to the template file
} }
@ -99,7 +99,7 @@ export function substituteArgs(content: string, args: string[]): string {
return result; return result;
} }
function loadTemplateFromFile(filePath: string, sourceLabel: string): PromptTemplate | null { function loadTemplateFromFile(filePath: string, source: string, sourceLabel: string): PromptTemplate | null {
try { try {
const rawContent = readFileSync(filePath, "utf-8"); const rawContent = readFileSync(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent); const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
@ -124,7 +124,7 @@ function loadTemplateFromFile(filePath: string, sourceLabel: string): PromptTemp
name, name,
description, description,
content: body, content: body,
source: sourceLabel, source,
filePath, filePath,
}; };
} catch { } catch {
@ -135,7 +135,7 @@ function loadTemplateFromFile(filePath: string, sourceLabel: string): PromptTemp
/** /**
* Scan a directory for .md files (non-recursive) and load them as prompt templates. * Scan a directory for .md files (non-recursive) and load them as prompt templates.
*/ */
function loadTemplatesFromDir(dir: string, sourceLabel: string): PromptTemplate[] { function loadTemplatesFromDir(dir: string, source: string, sourceLabel: string): PromptTemplate[] {
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
if (!existsSync(dir)) { if (!existsSync(dir)) {
@ -161,7 +161,7 @@ function loadTemplatesFromDir(dir: string, sourceLabel: string): PromptTemplate[
} }
if (isFile && entry.name.endsWith(".md")) { if (isFile && entry.name.endsWith(".md")) {
const template = loadTemplateFromFile(fullPath, sourceLabel); const template = loadTemplateFromFile(fullPath, source, sourceLabel);
if (template) { if (template) {
templates.push(template); templates.push(template);
} }
@ -196,9 +196,9 @@ function resolvePromptPath(p: string, cwd: string): string {
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
} }
function buildCustomSourceLabel(p: string): string { function buildPathSourceLabel(p: string): string {
const base = basename(p).replace(/\.md$/, "") || "custom"; const base = basename(p).replace(/\.md$/, "") || "path";
return `(custom:${base})`; return `(path:${base})`;
} }
/** /**
@ -217,11 +217,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
// 1. Load global templates from agentDir/prompts/ // 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir // Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...loadTemplatesFromDir(globalPromptsDir, "(user)")); templates.push(...loadTemplatesFromDir(globalPromptsDir, "user", "(user)"));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...loadTemplatesFromDir(projectPromptsDir, "(project)")); templates.push(...loadTemplatesFromDir(projectPromptsDir, "project", "(project)"));
// 3. Load explicit prompt paths // 3. Load explicit prompt paths
for (const rawPath of promptPaths) { for (const rawPath of promptPaths) {
@ -233,9 +233,9 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
try { try {
const stats = statSync(resolvedPath); const stats = statSync(resolvedPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
templates.push(...loadTemplatesFromDir(resolvedPath, buildCustomSourceLabel(resolvedPath))); templates.push(...loadTemplatesFromDir(resolvedPath, "path", buildPathSourceLabel(resolvedPath)));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) { } else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const template = loadTemplateFromFile(resolvedPath, buildCustomSourceLabel(resolvedPath)); const template = loadTemplateFromFile(resolvedPath, "path", buildPathSourceLabel(resolvedPath));
if (template) { if (template) {
templates.push(template); templates.push(template);
} }

View file

@ -1,6 +1,6 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve, sep } from "node:path";
import chalk from "chalk"; import chalk from "chalk";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js"; import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
@ -329,6 +329,9 @@ export class DefaultResourceLoader implements ResourceLoader {
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
this.skills = resolvedSkills.skills; this.skills = resolvedSkills.skills;
this.skillDiagnostics = resolvedSkills.diagnostics; this.skillDiagnostics = resolvedSkills.diagnostics;
for (const skill of this.skills) {
this.addDefaultMetadataForPath(skill.filePath);
}
const promptPaths = this.noPromptTemplates const promptPaths = this.noPromptTemplates
? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths) ? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths)
@ -351,6 +354,9 @@ export class DefaultResourceLoader implements ResourceLoader {
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult; const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
this.prompts = resolvedPrompts.prompts; this.prompts = resolvedPrompts.prompts;
this.promptDiagnostics = resolvedPrompts.diagnostics; this.promptDiagnostics = resolvedPrompts.diagnostics;
for (const prompt of this.prompts) {
this.addDefaultMetadataForPath(prompt.filePath);
}
const themePaths = this.noThemes const themePaths = this.noThemes
? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths) ? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths)
@ -367,6 +373,15 @@ export class DefaultResourceLoader implements ResourceLoader {
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
this.themes = resolvedThemes.themes; this.themes = resolvedThemes.themes;
this.themeDiagnostics = resolvedThemes.diagnostics; this.themeDiagnostics = resolvedThemes.diagnostics;
for (const theme of this.themes) {
if (theme.sourcePath) {
this.addDefaultMetadataForPath(theme.sourcePath);
}
}
for (const extension of this.extensionsResult.extensions) {
this.addDefaultMetadataForPath(extension.path);
}
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) }; const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
@ -588,6 +603,53 @@ export class DefaultResourceLoader implements ResourceLoader {
return undefined; return undefined;
} }
private addDefaultMetadataForPath(filePath: string): void {
if (!filePath || filePath.startsWith("<")) {
return;
}
const normalizedPath = resolve(filePath);
if (this.pathMetadata.has(normalizedPath) || this.pathMetadata.has(filePath)) {
return;
}
const agentRoots = [
join(this.agentDir, "skills"),
join(this.agentDir, "prompts"),
join(this.agentDir, "themes"),
join(this.agentDir, "extensions"),
];
const projectRoots = [
join(this.cwd, CONFIG_DIR_NAME, "skills"),
join(this.cwd, CONFIG_DIR_NAME, "prompts"),
join(this.cwd, CONFIG_DIR_NAME, "themes"),
join(this.cwd, CONFIG_DIR_NAME, "extensions"),
];
for (const root of agentRoots) {
if (this.isUnderPath(normalizedPath, root)) {
this.pathMetadata.set(normalizedPath, { source: "local", scope: "user", origin: "top-level" });
return;
}
}
for (const root of projectRoots) {
if (this.isUnderPath(normalizedPath, root)) {
this.pathMetadata.set(normalizedPath, { source: "local", scope: "project", origin: "top-level" });
return;
}
}
}
private isUnderPath(target: string, root: string): boolean {
const normalizedRoot = resolve(root);
if (target === normalizedRoot) {
return true;
}
const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return target.startsWith(prefix);
}
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> { private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
const conflicts: Array<{ path: string; message: string }> = []; const conflicts: Array<{ path: string; message: string }> = [];

View file

@ -366,9 +366,9 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
try { try {
const stats = statSync(resolvedPath); const stats = statSync(resolvedPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
addSkills(loadSkillsFromDirInternal(resolvedPath, "custom", true)); addSkills(loadSkillsFromDirInternal(resolvedPath, "path", true));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) { } else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const result = loadSkillFromFile(resolvedPath, "custom"); const result = loadSkillFromFile(resolvedPath, "path");
if (result.skill) { if (result.skill) {
addSkills({ skills: [result.skill], diagnostics: result.diagnostics }); addSkills({ skills: [result.skill], diagnostics: result.diagnostics });
} else { } else {

View file

@ -190,7 +190,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
return true; return true;
} }
const formatPackage = (pkg: (typeof globalPackages)[number], scope: "global" | "project") => { const formatPackage = (pkg: (typeof globalPackages)[number], scope: "user" | "project") => {
const source = typeof pkg === "string" ? pkg : pkg.source; const source = typeof pkg === "string" ? pkg : pkg.source;
const filtered = typeof pkg === "object"; const filtered = typeof pkg === "object";
const display = filtered ? `${source} (filtered)` : source; const display = filtered ? `${source} (filtered)` : source;
@ -203,9 +203,9 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
}; };
if (globalPackages.length > 0) { if (globalPackages.length > 0) {
console.log(chalk.bold("Global packages:")); console.log(chalk.bold("User packages:"));
for (const pkg of globalPackages) { for (const pkg of globalPackages) {
formatPackage(pkg, "global"); formatPackage(pkg, "user");
} }
} }

View file

@ -657,66 +657,101 @@ export class InteractiveMode {
return this.formatDisplayPath(fullPath); return this.formatDisplayPath(fullPath);
} }
/** private getDisplaySourceInfo(
* Group paths by source and scope using metadata. source: string,
* Returns sorted: local first, then global packages, then project packages. scope: string,
*/ ): { label: string; scopeLabel?: string; color: "accent" | "muted" } {
private groupPathsBySource( 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" };
}
private 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";
}
private isPackageSource(source: string): boolean {
return source.startsWith("npm:") || source.startsWith("git:");
}
private buildScopeGroups(
paths: string[], paths: string[],
metadata: Map<string, { source: string; scope: string; origin: string }>, metadata: Map<string, { source: string; scope: string; origin: string }>,
): Map<string, { scope: string; paths: string[] }> { ): Array<{ scope: "user" | "project" | "path"; paths: string[]; packages: Map<string, string[]> }> {
const groups = new Map<string, { scope: string; paths: 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) { for (const p of paths) {
const meta = this.findMetadata(p, metadata); const meta = this.findMetadata(p, metadata);
const source = meta?.source ?? "local"; const source = meta?.source ?? "local";
const scope = meta?.scope ?? "project"; const scope = meta?.scope ?? "project";
const groupKey = this.getScopeGroup(source, scope);
const group = groups[groupKey];
if (!groups.has(source)) { if (this.isPackageSource(source)) {
groups.set(source, { scope, paths: [] }); const list = group.packages.get(source) ?? [];
list.push(p);
group.packages.set(source, list);
} else {
group.paths.push(p);
} }
groups.get(source)!.paths.push(p);
} }
// Sort: local first, then global packages, then project packages return [groups.user, groups.project, groups.path].filter(
const sorted = new Map<string, { scope: string; paths: string[] }>(); (group) => group.paths.length > 0 || group.packages.size > 0,
const entries = Array.from(groups.entries()); );
// Local entries first
for (const [source, data] of entries) {
if (source === "local") sorted.set(source, data);
}
// Global packages
for (const [source, data] of entries) {
if (source !== "local" && data.scope === "global") sorted.set(source, data);
}
// Project packages
for (const [source, data] of entries) {
if (source !== "local" && data.scope === "project") sorted.set(source, data);
}
return sorted;
} }
/** private formatScopeGroups(
* Format grouped paths for display with colors. groups: Array<{ scope: "user" | "project" | "path"; paths: string[]; packages: Map<string, string[]> }>,
*/ options: {
private formatGroupedPaths( formatPath: (p: string) => string;
groups: Map<string, { scope: string; paths: string[] }>, formatPackagePath: (p: string, source: string) => string;
formatPath: (p: string, source: string) => string, },
): string { ): string {
const lines: string[] = []; const lines: string[] = [];
for (const [source, { scope, paths }] of groups) { for (const group of groups) {
const scopeLabel = scope === "global" ? "global" : scope === "project" ? "project" : ""; lines.push(` ${theme.fg("muted", group.scope)}`);
// Source name in accent, scope in muted
const sourceColor = source === "local" ? "muted" : "accent"; const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b));
const header = scopeLabel for (const p of sortedPaths) {
? `${theme.fg(sourceColor, source)} ${theme.fg("dim", `(${scopeLabel})`)}` lines.push(theme.fg("dim", ` ${options.formatPath(p)}`));
: theme.fg(sourceColor, source); }
lines.push(` ${header}`);
for (const p of paths) { const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b));
lines.push(theme.fg("dim", ` ${formatPath(p, source)}`)); for (const [source, paths] of sortedPackages) {
lines.push(` ${theme.fg("accent", source)}`);
const sortedPackagePaths = [...paths].sort((a, b) => a.localeCompare(b));
for (const p of sortedPackagePaths) {
lines.push(theme.fg("dim", ` ${options.formatPackagePath(p, source)}`));
}
} }
} }
@ -756,8 +791,9 @@ export class InteractiveMode {
const meta = this.findMetadata(p, metadata); const meta = this.findMetadata(p, metadata);
if (meta) { if (meta) {
const shortPath = this.getShortPath(p, meta.source); const shortPath = this.getShortPath(p, meta.source);
const scopeLabel = meta.scope === "global" ? "global" : meta.scope === "project" ? "project" : "temp"; const { label, scopeLabel } = this.getDisplaySourceInfo(meta.source, meta.scope);
return `${meta.source} (${scopeLabel}) ${shortPath}`; const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
return `${labelText} ${shortPath}`;
} }
return this.formatDisplayPath(p); return this.formatDisplayPath(p);
} }
@ -842,8 +878,11 @@ export class InteractiveMode {
const skills = this.session.resourceLoader.getSkills().skills; const skills = this.session.resourceLoader.getSkills().skills;
if (skills.length > 0) { if (skills.length > 0) {
const skillPaths = skills.map((s) => s.filePath); const skillPaths = skills.map((s) => s.filePath);
const groups = this.groupPathsBySource(skillPaths, metadata); const groups = this.buildScopeGroups(skillPaths, metadata);
const skillList = this.formatGroupedPaths(groups, (p, source) => this.getShortPath(p, source)); const skillList = this.formatScopeGroups(groups, {
formatPath: (p) => this.formatDisplayPath(p),
formatPackagePath: (p, source) => this.getShortPath(p, source),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }
@ -857,25 +896,20 @@ export class InteractiveMode {
const templates = this.session.promptTemplates; const templates = this.session.promptTemplates;
if (templates.length > 0) { if (templates.length > 0) {
// Group templates by source using metadata
const templatePaths = templates.map((t) => t.filePath); const templatePaths = templates.map((t) => t.filePath);
const groups = this.groupPathsBySource(templatePaths, metadata); const groups = this.buildScopeGroups(templatePaths, metadata);
const templateLines: string[] = []; const templateByPath = new Map(templates.map((t) => [t.filePath, t]));
for (const [source, { scope, paths }] of groups) { const templateList = this.formatScopeGroups(groups, {
const scopeLabel = scope === "global" ? "global" : scope === "project" ? "project" : ""; formatPath: (p) => {
const sourceColor = source === "local" ? "muted" : "accent"; const template = templateByPath.get(p);
const header = scopeLabel return template ? `/${template.name}` : this.formatDisplayPath(p);
? `${theme.fg(sourceColor, source)} ${theme.fg("dim", `(${scopeLabel})`)}` },
: theme.fg(sourceColor, source); formatPackagePath: (p) => {
templateLines.push(` ${header}`); const template = templateByPath.get(p);
for (const p of paths) { return template ? `/${template.name}` : this.formatDisplayPath(p);
const template = templates.find((t) => t.filePath === p); },
if (template) { });
templateLines.push(theme.fg("dim", ` /${template.name}`)); this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0));
}
}
}
this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateLines.join("\n")}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }
@ -888,8 +922,11 @@ export class InteractiveMode {
const extensionPaths = options?.extensionPaths ?? []; const extensionPaths = options?.extensionPaths ?? [];
if (extensionPaths.length > 0) { if (extensionPaths.length > 0) {
const groups = this.groupPathsBySource(extensionPaths, metadata); const groups = this.buildScopeGroups(extensionPaths, metadata);
const extList = this.formatGroupedPaths(groups, (p, source) => this.getShortPath(p, source)); const extList = this.formatScopeGroups(groups, {
formatPath: (p) => this.formatDisplayPath(p),
formatPackagePath: (p, source) => this.getShortPath(p, source),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Extensions")}\n${extList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Extensions")}\n${extList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }
@ -899,8 +936,11 @@ export class InteractiveMode {
const customThemes = loadedThemes.filter((t) => t.sourcePath); const customThemes = loadedThemes.filter((t) => t.sourcePath);
if (customThemes.length > 0) { if (customThemes.length > 0) {
const themePaths = customThemes.map((t) => t.sourcePath!); const themePaths = customThemes.map((t) => t.sourcePath!);
const groups = this.groupPathsBySource(themePaths, metadata); const groups = this.buildScopeGroups(themePaths, metadata);
const themeList = this.formatGroupedPaths(groups, (p, source) => this.getShortPath(p, source)); const themeList = this.formatScopeGroups(groups, {
formatPath: (p) => this.formatDisplayPath(p),
formatPackagePath: (p, source) => this.getShortPath(p, source),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }