From d501b9ca26dd80a11f19c0be87a15dce11b7ec02 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 23 Mar 2026 00:52:55 +0100 Subject: [PATCH] fix(coding-agent): attach source info to resources and commands, fixes #1734 --- .../examples/sdk/12-full-control.ts | 1 - .../coding-agent/src/core/agent-session.ts | 22 +- .../coding-agent/src/core/extensions/index.ts | 1 + .../src/core/extensions/loader.ts | 9 +- .../src/core/extensions/runner.ts | 42 +-- .../coding-agent/src/core/extensions/types.ts | 6 +- .../coding-agent/src/core/prompt-templates.ts | 34 +-- .../coding-agent/src/core/resource-loader.ts | 270 +++++++++-------- packages/coding-agent/src/core/skills.ts | 2 + .../coding-agent/src/core/slash-commands.ts | 3 + packages/coding-agent/src/core/source-info.ts | 19 ++ packages/coding-agent/src/index.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 283 +++++++++++------- .../src/modes/interactive/theme/theme.ts | 5 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 +- .../test/extensions-runner.test.ts | 25 +- .../coding-agent/test/resource-loader.test.ts | 14 +- .../test/sdk-codex-cache-probe-tool-loop.ts | 1 - packages/coding-agent/test/sdk-skills.test.ts | 2 - packages/coding-agent/test/utilities.ts | 1 - packages/mom/src/agent.ts | 1 - 21 files changed, 425 insertions(+), 321 deletions(-) create mode 100644 packages/coding-agent/src/core/source-info.ts diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 3d161dc9..135a52c4 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -53,7 +53,6 @@ const resourceLoader: ResourceLoader = { getSystemPrompt: () => `You are a minimal assistant. Available: read, bash. Be concise.`, getAppendSystemPrompt: () => [], - getPathMetadata: () => new Map(), extendResources: () => {}, reload: async () => {}, }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f3f96a69..b124b815 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -75,7 +75,7 @@ import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.j import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js"; import type { SettingsManager } from "./settings-manager.js"; -import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js"; +import type { SlashCommandInfo, SlashCommandLocation } from "./slash-commands.js"; import { buildSystemPrompt } from "./system-prompt.js"; import type { BashOperations } from "./tools/bash.js"; import { createAllToolDefinitions } from "./tools/index.js"; @@ -2091,23 +2091,20 @@ export class AgentSession { return undefined; }; - const reservedBuiltins = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); - const getCommands = (): SlashCommandInfo[] => { - const extensionCommands: SlashCommandInfo[] = runner - .getRegisteredCommandsWithPaths() - .filter(({ command }) => !reservedBuiltins.has(command.name)) - .map(({ command, extensionPath }) => ({ - name: command.name, - description: command.description, - source: "extension", - path: extensionPath, - })); + const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({ + name: command.name, + description: command.description, + source: "extension", + sourceInfo: command.sourceInfo, + path: command.extensionPath, + })); const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({ name: template.name, description: template.description, source: "prompt", + sourceInfo: template.sourceInfo, location: normalizeLocation(template.source), path: template.filePath, })); @@ -2116,6 +2113,7 @@ export class AgentSession { name: `skill:${skill.name}`, description: skill.description, source: "skill", + sourceInfo: skill.sourceInfo, location: normalizeLocation(skill.source), path: skill.filePath, })); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index a473d139..4535dac4 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -3,6 +3,7 @@ */ export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js"; +export type { SourceInfo } from "../source-info.js"; export { createExtensionRuntime, discoverAndLoadExtensions, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 0dd00fe6..1afff56d 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -179,8 +179,13 @@ function createExtensionAPI( runtime.refreshTools(); }, - registerCommand(name: string, options: Omit): void { - extension.commands.set(name, { name, ...options }); + registerCommand(name: string, options: Omit): void { + extension.commands.set(name, { + name, + extensionPath: extension.path, + sourceInfo: extension.sourceInfo, + ...options, + }); }, registerShortcut( diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index dc557d4e..6e2ce988 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -467,27 +467,16 @@ export class ExtensionRunner { return undefined; } - getRegisteredCommands(reserved?: Set): RegisteredCommand[] { - this.commandDiagnostics = []; - + private collectRegisteredCommands(diagnostics?: ResourceDiagnostic[]): RegisteredCommand[] { const commands: RegisteredCommand[] = []; const commandOwners = new Map(); for (const ext of this.extensions) { for (const command of ext.commands.values()) { - if (reserved?.has(command.name)) { - const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`; - this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); - if (!this.hasUI()) { - console.warn(message); - } - continue; - } - const existingOwner = commandOwners.get(command.name); if (existingOwner) { const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`; - this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); - if (!this.hasUI()) { + diagnostics?.push({ type: "warning", message, path: ext.path }); + if (diagnostics && !this.hasUI()) { console.warn(message); } continue; @@ -500,28 +489,19 @@ export class ExtensionRunner { return commands; } + getRegisteredCommands(): RegisteredCommand[] { + const diagnostics: ResourceDiagnostic[] = []; + const commands = this.collectRegisteredCommands(diagnostics); + this.commandDiagnostics = diagnostics; + return commands; + } + getCommandDiagnostics(): ResourceDiagnostic[] { return this.commandDiagnostics; } - getRegisteredCommandsWithPaths(): Array<{ command: RegisteredCommand; extensionPath: string }> { - const result: Array<{ command: RegisteredCommand; extensionPath: string }> = []; - for (const ext of this.extensions) { - for (const command of ext.commands.values()) { - result.push({ command, extensionPath: ext.path }); - } - } - return result; - } - getCommand(name: string): RegisteredCommand | undefined { - for (const ext of this.extensions) { - const command = ext.commands.get(name); - if (command) { - return command; - } - } - return undefined; + return this.collectRegisteredCommands().find((command) => command.name === name); } /** diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 65c66e59..9102f965 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -55,6 +55,7 @@ import type { SessionManager, } from "../session-manager.js"; import type { SlashCommandInfo } from "../slash-commands.js"; +import type { SourceInfo } from "../source-info.js"; import type { BashOperations } from "../tools/bash.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { @@ -960,6 +961,8 @@ export type MessageRenderer = ( export interface RegisteredCommand { name: string; + extensionPath: string; + sourceInfo?: SourceInfo; description?: string; getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null; handler: (args: string, ctx: ExtensionCommandContext) => Promise; @@ -1035,7 +1038,7 @@ export interface ExtensionAPI { // ========================================================================= /** Register a custom command. */ - registerCommand(name: string, options: Omit): void; + registerCommand(name: string, options: Omit): void; /** Register a keyboard shortcut. */ registerShortcut( @@ -1414,6 +1417,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction export interface Extension { path: string; resolvedPath: string; + sourceInfo?: SourceInfo; handlers: Map; tools: Map; messageRenderers: Map; diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index d7ed0953..fa0a8774 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -3,6 +3,7 @@ import { homedir } from "os"; import { basename, isAbsolute, join, resolve, sep } from "path"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; +import type { SourceInfo } from "./source-info.js"; /** * Represents a prompt template loaded from a markdown file @@ -12,6 +13,7 @@ export interface PromptTemplate { description: string; content: string; source: string; // "user", "project", or "path" + sourceInfo?: SourceInfo; filePath: string; // Absolute path to the template file } @@ -99,7 +101,7 @@ export function substituteArgs(content: string, args: string[]): string { return result; } -function loadTemplateFromFile(filePath: string, source: string, sourceLabel: string): PromptTemplate | null { +function loadTemplateFromFile(filePath: string, source: string): PromptTemplate | null { try { const rawContent = readFileSync(filePath, "utf-8"); const { frontmatter, body } = parseFrontmatter>(rawContent); @@ -117,9 +119,6 @@ function loadTemplateFromFile(filePath: string, source: string, sourceLabel: str } } - // Append source to description - description = description ? `${description} ${sourceLabel}` : sourceLabel; - return { name, description, @@ -135,7 +134,7 @@ function loadTemplateFromFile(filePath: string, source: string, sourceLabel: str /** * Scan a directory for .md files (non-recursive) and load them as prompt templates. */ -function loadTemplatesFromDir(dir: string, source: string, sourceLabel: string): PromptTemplate[] { +function loadTemplatesFromDir(dir: string, source: string): PromptTemplate[] { const templates: PromptTemplate[] = []; if (!existsSync(dir)) { @@ -161,7 +160,7 @@ function loadTemplatesFromDir(dir: string, source: string, sourceLabel: string): } if (isFile && entry.name.endsWith(".md")) { - const template = loadTemplateFromFile(fullPath, source, sourceLabel); + const template = loadTemplateFromFile(fullPath, source); if (template) { templates.push(template); } @@ -198,11 +197,6 @@ function resolvePromptPath(p: string, cwd: string): string { return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); } -function buildPathSourceLabel(p: string): string { - const base = basename(p).replace(/\.md$/, "") || "path"; - return `(path:${base})`; -} - /** * Load all prompt templates from: * 1. Global: agentDir/prompts/ @@ -221,11 +215,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P // 1. Load global templates from agentDir/prompts/ // Note: if agentDir is provided, it should be the agent dir, not the prompts dir const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; - templates.push(...loadTemplatesFromDir(globalPromptsDir, "user", "(user)")); + templates.push(...loadTemplatesFromDir(globalPromptsDir, "user")); // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); - templates.push(...loadTemplatesFromDir(projectPromptsDir, "project", "(project)")); + templates.push(...loadTemplatesFromDir(projectPromptsDir, "project")); } const userPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; @@ -240,16 +234,16 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P return target.startsWith(prefix); }; - const getSourceInfo = (resolvedPath: string): { source: string; label: string } => { + const getSource = (resolvedPath: string): string => { if (!includeDefaults) { if (isUnderPath(resolvedPath, userPromptsDir)) { - return { source: "user", label: "(user)" }; + return "user"; } if (isUnderPath(resolvedPath, projectPromptsDir)) { - return { source: "project", label: "(project)" }; + return "project"; } } - return { source: "path", label: buildPathSourceLabel(resolvedPath) }; + return "path"; }; // 3. Load explicit prompt paths @@ -261,11 +255,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P try { const stats = statSync(resolvedPath); - const { source, label } = getSourceInfo(resolvedPath); + const source = getSource(resolvedPath); if (stats.isDirectory()) { - templates.push(...loadTemplatesFromDir(resolvedPath, source, label)); + templates.push(...loadTemplatesFromDir(resolvedPath, source)); } else if (stats.isFile() && resolvedPath.endsWith(".md")) { - const template = loadTemplateFromFile(resolvedPath, source, label); + const template = loadTemplateFromFile(resolvedPath, source); if (template) { templates.push(template); } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 1c650f79..b77c34cd 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -17,6 +17,7 @@ import { loadPromptTemplates } from "./prompt-templates.js"; import { SettingsManager } from "./settings-manager.js"; import type { Skill } from "./skills.js"; import { loadSkills } from "./skills.js"; +import { createSourceInfo, type SourceInfo } from "./source-info.js"; export interface ResourceExtensionPaths { skillPaths?: Array<{ path: string; metadata: PathMetadata }>; @@ -32,7 +33,6 @@ export interface ResourceLoader { getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; getSystemPrompt(): string | undefined; getAppendSystemPrompt(): string[]; - getPathMetadata(): Map; extendResources(paths: ResourceExtensionPaths): void; reload(): Promise; } @@ -193,8 +193,10 @@ export class DefaultResourceLoader implements ResourceLoader { private agentsFiles: Array<{ path: string; content: string }>; private systemPrompt?: string; private appendSystemPrompt: string[]; - private pathMetadata: Map; private lastSkillPaths: string[]; + private extensionSkillSourceInfos: Map; + private extensionPromptSourceInfos: Map; + private extensionThemeSourceInfos: Map; private lastPromptPaths: string[]; private lastThemePaths: string[]; @@ -236,8 +238,10 @@ export class DefaultResourceLoader implements ResourceLoader { this.themeDiagnostics = []; this.agentsFiles = []; this.appendSystemPrompt = []; - this.pathMetadata = new Map(); this.lastSkillPaths = []; + this.extensionSkillSourceInfos = new Map(); + this.extensionPromptSourceInfos = new Map(); + this.extensionThemeSourceInfos = new Map(); this.lastPromptPaths = []; this.lastThemePaths = []; } @@ -270,21 +274,27 @@ export class DefaultResourceLoader implements ResourceLoader { return this.appendSystemPrompt; } - getPathMetadata(): Map { - return this.pathMetadata; - } - extendResources(paths: ResourceExtensionPaths): void { const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []); const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []); const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []); + for (const entry of skillPaths) { + this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + for (const entry of promptPaths) { + this.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + for (const entry of themePaths) { + this.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + if (skillPaths.length > 0) { this.lastSkillPaths = this.mergePaths( this.lastSkillPaths, skillPaths.map((entry) => entry.path), ); - this.updateSkillsFromPaths(this.lastSkillPaths, skillPaths); + this.updateSkillsFromPaths(this.lastSkillPaths); } if (promptPaths.length > 0) { @@ -292,7 +302,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastPromptPaths, promptPaths.map((entry) => entry.path), ); - this.updatePromptsFromPaths(this.lastPromptPaths, promptPaths); + this.updatePromptsFromPaths(this.lastPromptPaths); } if (themePaths.length > 0) { @@ -300,7 +310,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastThemePaths, themePaths.map((entry) => entry.path), ); - this.updateThemesFromPaths(this.lastThemePaths, themePaths); + this.updateThemesFromPaths(this.lastThemePaths); } } @@ -309,14 +319,19 @@ export class DefaultResourceLoader implements ResourceLoader { const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); + const metadataByPath = new Map(); + + this.extensionSkillSourceInfos = new Map(); + this.extensionPromptSourceInfos = new Map(); + this.extensionThemeSourceInfos = new Map(); // Helper to extract enabled paths and store metadata const getEnabledResources = ( resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, ): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => { for (const r of resources) { - if (!this.pathMetadata.has(r.path)) { - this.pathMetadata.set(r.path, r.metadata); + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, r.metadata); } } return resources.filter((r) => r.enabled); @@ -325,9 +340,6 @@ export class DefaultResourceLoader implements ResourceLoader { const getEnabledPaths = ( resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, ): string[] => getEnabledResources(resources).map((r) => r.path); - - // Store metadata and get enabled paths - this.pathMetadata = new Map(); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); @@ -347,8 +359,8 @@ export class DefaultResourceLoader implements ResourceLoader { } const skillFile = join(resource.path, "SKILL.md"); if (existsSync(skillFile)) { - if (!this.pathMetadata.has(skillFile)) { - this.pathMetadata.set(skillFile, resource.metadata); + if (!metadataByPath.has(skillFile)) { + metadataByPath.set(skillFile, resource.metadata); } return skillFile; } @@ -359,13 +371,13 @@ export class DefaultResourceLoader implements ResourceLoader { // Add CLI paths metadata for (const r of cliExtensionPaths.extensions) { - if (!this.pathMetadata.has(r.path)) { - this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); } } for (const r of cliExtensionPaths.skills) { - if (!this.pathMetadata.has(r.path)) { - this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); } } @@ -391,31 +403,28 @@ export class DefaultResourceLoader implements ResourceLoader { } this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; + this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath); const skillPaths = this.noSkills ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths) : this.mergePaths([...enabledSkills, ...cliEnabledSkills], this.additionalSkillPaths); this.lastSkillPaths = skillPaths; - this.updateSkillsFromPaths(skillPaths); + this.updateSkillsFromPaths(skillPaths, metadataByPath); const promptPaths = this.noPromptTemplates ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths) : this.mergePaths([...enabledPrompts, ...cliEnabledPrompts], this.additionalPromptTemplatePaths); this.lastPromptPaths = promptPaths; - this.updatePromptsFromPaths(promptPaths); + this.updatePromptsFromPaths(promptPaths, metadataByPath); const themePaths = this.noThemes ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths) : this.mergePaths([...enabledThemes, ...cliEnabledThemes], this.additionalThemePaths); this.lastThemePaths = themePaths; - this.updateThemesFromPaths(themePaths); - - for (const extension of this.extensionsResult.extensions) { - this.addDefaultMetadataForPath(extension.path); - } + this.updateThemesFromPaths(themePaths, metadataByPath); const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; @@ -444,10 +453,7 @@ export class DefaultResourceLoader implements ResourceLoader { })); } - private updateSkillsFromPaths( - skillPaths: string[], - extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [], - ): void { + private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map): void { let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; if (this.noSkills && skillPaths.length === 0) { skillsResult = { skills: [], diagnostics: [] }; @@ -460,21 +466,16 @@ export class DefaultResourceLoader implements ResourceLoader { }); } const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; - this.skills = resolvedSkills.skills; + this.skills = resolvedSkills.skills.map((skill) => ({ + ...skill, + sourceInfo: + this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ?? + this.getDefaultSourceInfoForPath(skill.filePath), + })); this.skillDiagnostics = resolvedSkills.diagnostics; - this.applyExtensionMetadata( - extensionPaths, - this.skills.map((skill) => skill.filePath), - ); - for (const skill of this.skills) { - this.addDefaultMetadataForPath(skill.filePath); - } } - private updatePromptsFromPaths( - promptPaths: string[], - extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [], - ): void { + private updatePromptsFromPaths(promptPaths: string[], metadataByPath?: Map): void { let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; if (this.noPromptTemplates && promptPaths.length === 0) { promptsResult = { prompts: [], diagnostics: [] }; @@ -488,21 +489,16 @@ export class DefaultResourceLoader implements ResourceLoader { promptsResult = this.dedupePrompts(allPrompts); } const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult; - this.prompts = resolvedPrompts.prompts; + this.prompts = resolvedPrompts.prompts.map((prompt) => ({ + ...prompt, + sourceInfo: + this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ?? + this.getDefaultSourceInfoForPath(prompt.filePath), + })); this.promptDiagnostics = resolvedPrompts.diagnostics; - this.applyExtensionMetadata( - extensionPaths, - this.prompts.map((prompt) => prompt.filePath), - ); - for (const prompt of this.prompts) { - this.addDefaultMetadataForPath(prompt.filePath); - } } - private updateThemesFromPaths( - themePaths: string[], - extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [], - ): void { + private updateThemesFromPaths(themePaths: string[], metadataByPath?: Map): void { let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; if (this.noThemes && themePaths.length === 0) { themesResult = { themes: [], diagnostics: [] }; @@ -512,49 +508,115 @@ export class DefaultResourceLoader implements ResourceLoader { themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] }; } const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; - this.themes = resolvedThemes.themes; + this.themes = resolvedThemes.themes.map((theme) => { + const sourcePath = theme.sourcePath; + theme.sourceInfo = sourcePath + ? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ?? + this.getDefaultSourceInfoForPath(sourcePath)) + : undefined; + return theme; + }); this.themeDiagnostics = resolvedThemes.diagnostics; - const themePathsWithSource = this.themes.flatMap((theme) => (theme.sourcePath ? [theme.sourcePath] : [])); - this.applyExtensionMetadata(extensionPaths, themePathsWithSource); - for (const theme of this.themes) { - if (theme.sourcePath) { - this.addDefaultMetadataForPath(theme.sourcePath); + } + + private applyExtensionSourceInfo(extensions: Extension[], metadataByPath: Map): void { + for (const extension of extensions) { + extension.sourceInfo = + this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ?? + this.getDefaultSourceInfoForPath(extension.path); + for (const command of extension.commands.values()) { + command.sourceInfo = extension.sourceInfo; } } } - private applyExtensionMetadata( - extensionPaths: Array<{ path: string; metadata: PathMetadata }>, - resourcePaths: string[], - ): void { - if (extensionPaths.length === 0) { - return; + private findSourceInfoForPath( + resourcePath: string, + extraSourceInfos?: Map, + metadataByPath?: Map, + ): SourceInfo | undefined { + if (!resourcePath) { + return undefined; } - const normalized = extensionPaths.map((entry) => ({ - path: resolve(entry.path), - metadata: entry.metadata, - })); + if (resourcePath.startsWith("<")) { + return this.getDefaultSourceInfoForPath(resourcePath); + } - for (const entry of normalized) { - if (!this.pathMetadata.has(entry.path)) { - this.pathMetadata.set(entry.path, entry.metadata); + const normalizedResourcePath = resolve(resourcePath); + if (extraSourceInfos) { + for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) { + const normalizedSourcePath = resolve(sourcePath); + if ( + normalizedResourcePath === normalizedSourcePath || + normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) + ) { + return { ...sourceInfo, path: resourcePath }; + } } } - for (const resourcePath of resourcePaths) { - const normalizedResourcePath = resolve(resourcePath); - if (this.pathMetadata.has(normalizedResourcePath) || this.pathMetadata.has(resourcePath)) { - continue; + if (metadataByPath) { + const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath); + if (exact) { + return createSourceInfo(resourcePath, exact); } - const match = normalized.find( - (entry) => - normalizedResourcePath === entry.path || normalizedResourcePath.startsWith(`${entry.path}${sep}`), - ); - if (match) { - this.pathMetadata.set(normalizedResourcePath, match.metadata); + + for (const [sourcePath, metadata] of metadataByPath.entries()) { + const normalizedSourcePath = resolve(sourcePath); + if ( + normalizedResourcePath === normalizedSourcePath || + normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) + ) { + return createSourceInfo(resourcePath, metadata); + } } } + + return undefined; + } + + private getDefaultSourceInfoForPath(filePath: string): SourceInfo | undefined { + if (!filePath) { + return undefined; + } + + if (filePath.startsWith("<") && filePath.endsWith(">")) { + return { + path: filePath, + source: filePath.slice(1, -1).split(":")[0] || "temporary", + scope: "temporary", + origin: "top-level", + }; + } + + const normalizedPath = resolve(filePath); + 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)) { + return { path: filePath, source: "local", scope: "user", origin: "top-level" }; + } + } + + for (const root of projectRoots) { + if (this.isUnderPath(normalizedPath, root)) { + return { path: filePath, source: "local", scope: "project", origin: "top-level" }; + } + } + + return undefined; } private mergePaths(primary: string[], additional: string[]): string[] { @@ -767,44 +829,6 @@ export class DefaultResourceLoader implements ResourceLoader { 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) { diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 3f046425..149c2135 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "pat import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; import type { ResourceDiagnostic } from "./diagnostics.js"; +import type { SourceInfo } from "./source-info.js"; /** Max name length per spec */ const MAX_NAME_LENGTH = 64; @@ -76,6 +77,7 @@ export interface Skill { filePath: string; baseDir: string; source: string; + sourceInfo?: SourceInfo; disableModelInvocation: boolean; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 45735177..2cc3f74a 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -1,3 +1,5 @@ +import type { SourceInfo } from "./source-info.js"; + export type SlashCommandSource = "extension" | "prompt" | "skill"; export type SlashCommandLocation = "user" | "project" | "path"; @@ -6,6 +8,7 @@ export interface SlashCommandInfo { name: string; description?: string; source: SlashCommandSource; + sourceInfo?: SourceInfo; location?: SlashCommandLocation; path?: string; } diff --git a/packages/coding-agent/src/core/source-info.ts b/packages/coding-agent/src/core/source-info.ts new file mode 100644 index 00000000..425eaf2f --- /dev/null +++ b/packages/coding-agent/src/core/source-info.ts @@ -0,0 +1,19 @@ +import type { PathMetadata } from "./package-manager.js"; + +export interface SourceInfo { + path?: string; + source: string; + scope: "user" | "project" | "temporary"; + origin: "package" | "top-level"; + baseDir?: string; +} + +export function createSourceInfo(path: string | undefined, metadata: PathMetadata): SourceInfo { + return { + path, + source: metadata.source, + scope: metadata.scope, + origin: metadata.origin, + baseDir: metadata.baseDir, + }; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 4465ee67..030c9d51 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -110,6 +110,7 @@ export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource, + SourceInfo, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c35705c3..135dbf82 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -63,10 +63,12 @@ import { DefaultPackageManager } from "../../core/package-manager.js"; import type { ResourceDiagnostic } from "../../core/resource-loader.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; +import type { SourceInfo } from "../../core/source-info.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard } from "../../utils/clipboard.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; +import { parseGitUrl } from "../../utils/git.js"; import { ensureTool } from "../../utils/tools-manager.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; @@ -286,6 +288,55 @@ export class InteractiveMode { initTheme(this.settingsManager.getTheme(), true); } + private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { + if (!sourceInfo) { + return undefined; + } + + const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t"; + const source = sourceInfo.source.trim(); + + if (source === "auto" || source === "local" || source === "cli") { + return scopePrefix; + } + + if (source.startsWith("npm:")) { + return `${scopePrefix}:${source}`; + } + + const gitSource = parseGitUrl(source); + if (gitSource) { + const ref = gitSource.ref ? `@${gitSource.ref}` : ""; + return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`; + } + + return scopePrefix; + } + + private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined { + const sourceTag = this.getAutocompleteSourceTag(sourceInfo); + if (!sourceTag) { + return description; + } + return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`; + } + + private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner | undefined): ResourceDiagnostic[] { + if (!extensionRunner) { + return []; + } + + const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); + return extensionRunner + .getRegisteredCommands() + .filter((command) => builtinNames.has(command.name)) + .map((command) => ({ + type: "warning" as const, + message: `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`, + path: command.extensionPath, + })); + } + private setupAutocomplete(fdPath: string | undefined): void { // Define commands for autocomplete const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ @@ -327,16 +378,16 @@ export class InteractiveMode { // Convert prompt templates to SlashCommand format for autocomplete const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ name: cmd.name, - description: cmd.description, + description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), })); // Convert extension commands to SlashCommand format const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); const extensionCommands: SlashCommand[] = ( - this.session.extensionRunner?.getRegisteredCommands(builtinCommandNames) ?? [] + this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? [] ).map((cmd) => ({ name: cmd.name, - description: cmd.description ?? "(extension command)", + description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), getArgumentCompletions: cmd.getArgumentCompletions, })); @@ -347,7 +398,10 @@ export class InteractiveMode { for (const skill of this.session.resourceLoader.getSkills().skills) { const commandName = `skill:${skill.name}`; this.skillCommands.set(commandName, skill.filePath); - skillCommandList.push({ name: commandName, description: skill.description }); + skillCommandList.push({ + name: commandName, + description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo), + }); } } @@ -724,27 +778,28 @@ export class InteractiveMode { /** * Get a short path relative to the package root for display. */ - private getShortPath(fullPath: string, source: string): string { - // For npm packages, show path relative to node_modules/pkg/ + private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string { + const source = sourceInfo?.source ?? ""; const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/); if (npmMatch && source.startsWith("npm:")) { return npmMatch[2]; } - // For git packages, show path relative to repo root const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); if (gitMatch && source.startsWith("git:")) { return gitMatch[1]; } - // For local/auto, just use formatDisplayPath return this.formatDisplayPath(fullPath); } - private getDisplaySourceInfo( - source: string, - scope: string, - ): { label: string; scopeLabel?: string; color: "accent" | "muted" } { + private getDisplaySourceInfo(sourceInfo?: SourceInfo): { + label: string; + scopeLabel?: string; + color: "accent" | "muted"; + } { + const source = sourceInfo?.source ?? "local"; + const scope = sourceInfo?.scope ?? "project"; if (source === "local") { if (scope === "user") { return { label: "user", color: "muted" }; @@ -767,43 +822,49 @@ export class InteractiveMode { return { label: source, scopeLabel, color: "accent" }; } - private getScopeGroup(source: string, scope: string): "user" | "project" | "path" { + private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" { + const source = sourceInfo?.source ?? "local"; + const scope = sourceInfo?.scope ?? "project"; if (source === "cli" || scope === "temporary") return "path"; if (scope === "user") return "user"; if (scope === "project") return "project"; return "path"; } - private isPackageSource(source: string): boolean { + private isPackageSource(sourceInfo?: SourceInfo): boolean { + const source = sourceInfo?.source ?? ""; return source.startsWith("npm:") || source.startsWith("git:"); } - private buildScopeGroups( - paths: string[], - metadata: Map, - ): Array<{ scope: "user" | "project" | "path"; paths: string[]; packages: Map }> { + private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{ + scope: "user" | "project" | "path"; + paths: Array<{ path: string; sourceInfo?: SourceInfo }>; + packages: Map>; + }> { const groups: Record< "user" | "project" | "path", - { scope: "user" | "project" | "path"; paths: string[]; packages: Map } + { + scope: "user" | "project" | "path"; + paths: Array<{ path: string; sourceInfo?: SourceInfo }>; + packages: Map>; + } > = { 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 = this.findMetadata(p, metadata); - const source = meta?.source ?? "local"; - const scope = meta?.scope ?? "project"; - const groupKey = this.getScopeGroup(source, scope); + for (const item of items) { + const groupKey = this.getScopeGroup(item.sourceInfo); const group = groups[groupKey]; + const source = item.sourceInfo?.source ?? "local"; - if (this.isPackageSource(source)) { + if (this.isPackageSource(item.sourceInfo)) { const list = group.packages.get(source) ?? []; - list.push(p); + list.push(item); group.packages.set(source, list); } else { - group.paths.push(p); + group.paths.push(item); } } @@ -813,10 +874,14 @@ export class InteractiveMode { } private formatScopeGroups( - groups: Array<{ scope: "user" | "project" | "path"; paths: string[]; packages: Map }>, + groups: Array<{ + scope: "user" | "project" | "path"; + paths: Array<{ path: string; sourceInfo?: SourceInfo }>; + packages: Map>; + }>, options: { - formatPath: (p: string) => string; - formatPackagePath: (p: string, source: string) => string; + formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string; + formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string; }, ): string { const lines: string[] = []; @@ -824,17 +889,17 @@ export class InteractiveMode { 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 sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path)); + for (const item of sortedPaths) { + lines.push(theme.fg("dim", ` ${options.formatPath(item)}`)); } const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b)); - for (const [source, paths] of sortedPackages) { + for (const [source, items] 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)}`)); + const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path)); + for (const item of sortedPackagePaths) { + lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`)); } } } @@ -842,53 +907,31 @@ export class InteractiveMode { return lines.join("\n"); } - /** - * Find metadata for a path, checking parent directories if exact match fails. - * Package manager stores metadata for directories, but we display file paths. - */ - private findMetadata( - p: string, - metadata: Map, - ): { source: string; scope: string; origin: string } | undefined { - // Try exact match first - const exact = metadata.get(p); + private findSourceInfoForPath(p: string, sourceInfos: Map): SourceInfo | undefined { + const exact = sourceInfos.get(p); if (exact) return exact; - // Try parent directories (package manager stores directory paths) let current = p; while (current.includes("/")) { current = current.substring(0, current.lastIndexOf("/")); - const parent = metadata.get(current); + const parent = sourceInfos.get(current); if (parent) return parent; } return undefined; } - /** - * Format a path with its source/scope info from metadata. - */ - private formatPathWithSource( - p: string, - metadata: Map, - ): string { - const meta = this.findMetadata(p, metadata); - if (meta) { - const shortPath = this.getShortPath(p, meta.source); - const { label, scopeLabel } = this.getDisplaySourceInfo(meta.source, meta.scope); + private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string { + if (sourceInfo) { + const shortPath = this.getShortPath(p, sourceInfo); + const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo); const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; return `${labelText} ${shortPath}`; } return this.formatDisplayPath(p); } - /** - * Format resource diagnostics with nice collision display using metadata. - */ - private formatDiagnostics( - diagnostics: readonly ResourceDiagnostic[], - metadata: Map, - ): string { + private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map): string { const lines: string[] = []; // Group collision diagnostics by name @@ -910,29 +953,28 @@ export class InteractiveMode { const first = collisionList[0]?.collision; if (!first) continue; lines.push(theme.fg("warning", ` "${name}" collision:`)); - // Show winner lines.push( - theme.fg("dim", ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, metadata)}`), + theme.fg( + "dim", + ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`, + ), ); - // Show all losers for (const d of collisionList) { if (d.collision) { lines.push( theme.fg( "dim", - ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`, + ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`, ), ); } } } - // Format other diagnostics (skill name collisions, parse errors, etc.) for (const d of otherDiagnostics) { if (d.path) { - // Use metadata-aware formatting for paths - const sourceInfo = this.formatPathWithSource(d.path, metadata); - lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`)); + const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos)); + lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`)); lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); } else { lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); @@ -943,7 +985,7 @@ export class InteractiveMode { } private showLoadedResources(options?: { - extensionPaths?: string[]; + extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>; force?: boolean; showDiagnosticsWhenQuiet?: boolean; }): void { @@ -953,12 +995,38 @@ export class InteractiveMode { return; } - const metadata = this.session.resourceLoader.getPathMetadata(); const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); const skillsResult = this.session.resourceLoader.getSkills(); const promptsResult = this.session.resourceLoader.getPrompts(); const themesResult = this.session.resourceLoader.getThemes(); + const extensions = + options?.extensions ?? + this.session.resourceLoader.getExtensions().extensions.map((extension) => ({ + path: extension.path, + sourceInfo: extension.sourceInfo, + })); + const sourceInfos = new Map(); + for (const extension of extensions) { + if (extension.sourceInfo) { + sourceInfos.set(extension.path, extension.sourceInfo); + } + } + for (const skill of skillsResult.skills) { + if (skill.sourceInfo) { + sourceInfos.set(skill.filePath, skill.sourceInfo); + } + } + for (const prompt of promptsResult.prompts) { + if (prompt.sourceInfo) { + sourceInfos.set(prompt.filePath, prompt.sourceInfo); + } + } + for (const loadedTheme of themesResult.themes) { + if (loadedTheme.sourcePath && loadedTheme.sourceInfo) { + sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo); + } + } if (showListing) { const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; @@ -973,11 +1041,12 @@ export class InteractiveMode { const skills = skillsResult.skills; if (skills.length > 0) { - const skillPaths = skills.map((s) => s.filePath); - const groups = this.buildScopeGroups(skillPaths, metadata); + const groups = this.buildScopeGroups( + skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })), + ); const skillList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), + formatPath: (item) => this.formatDisplayPath(item.path), + formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), }); this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); @@ -985,29 +1054,29 @@ export class InteractiveMode { const templates = this.session.promptTemplates; if (templates.length > 0) { - const templatePaths = templates.map((t) => t.filePath); - const groups = this.buildScopeGroups(templatePaths, metadata); + const groups = this.buildScopeGroups( + templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })), + ); const templateByPath = new Map(templates.map((t) => [t.filePath, t])); const templateList = this.formatScopeGroups(groups, { - formatPath: (p) => { - const template = templateByPath.get(p); - return template ? `/${template.name}` : this.formatDisplayPath(p); + formatPath: (item) => { + const template = templateByPath.get(item.path); + return template ? `/${template.name}` : this.formatDisplayPath(item.path); }, - formatPackagePath: (p) => { - const template = templateByPath.get(p); - return template ? `/${template.name}` : this.formatDisplayPath(p); + formatPackagePath: (item) => { + const template = templateByPath.get(item.path); + return template ? `/${template.name}` : this.formatDisplayPath(item.path); }, }); this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); } - const extensionPaths = options?.extensionPaths ?? []; - if (extensionPaths.length > 0) { - const groups = this.buildScopeGroups(extensionPaths, metadata); + if (extensions.length > 0) { + const groups = this.buildScopeGroups(extensions); const extList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), + formatPath: (item) => this.formatDisplayPath(item.path), + formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), }); this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); @@ -1017,11 +1086,15 @@ export class InteractiveMode { const loadedThemes = themesResult.themes; const customThemes = loadedThemes.filter((t) => t.sourcePath); if (customThemes.length > 0) { - const themePaths = customThemes.map((t) => t.sourcePath!); - const groups = this.buildScopeGroups(themePaths, metadata); + const groups = this.buildScopeGroups( + customThemes.map((loadedTheme) => ({ + path: loadedTheme.sourcePath!, + sourceInfo: loadedTheme.sourceInfo, + })), + ); const themeList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), + formatPath: (item) => this.formatDisplayPath(item.path), + formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), }); this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); @@ -1031,14 +1104,14 @@ export class InteractiveMode { if (showDiagnostics) { const skillDiagnostics = skillsResult.diagnostics; if (skillDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics(skillDiagnostics, metadata); + const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos); this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); } const promptDiagnostics = promptsResult.diagnostics; if (promptDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics(promptDiagnostics, metadata); + const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos); this.chatContainer.addChild( new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0), ); @@ -1055,12 +1128,13 @@ export class InteractiveMode { const commandDiagnostics = this.session.extensionRunner?.getCommandDiagnostics() ?? []; extensionDiagnostics.push(...commandDiagnostics); + extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner)); const shortcutDiagnostics = this.session.extensionRunner?.getShortcutDiagnostics() ?? []; extensionDiagnostics.push(...shortcutDiagnostics); if (extensionDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics(extensionDiagnostics, metadata); + const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos); this.chatContainer.addChild( new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0), ); @@ -1069,7 +1143,7 @@ export class InteractiveMode { const themeDiagnostics = themesResult.diagnostics; if (themeDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics(themeDiagnostics, metadata); + const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos); this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); } @@ -1169,12 +1243,12 @@ export class InteractiveMode { const extensionRunner = this.session.extensionRunner; if (!extensionRunner) { - this.showLoadedResources({ extensionPaths: [], force: false }); + this.showLoadedResources({ extensions: [], force: false }); return; } this.setupExtensionShortcuts(extensionRunner); - this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false }); + this.showLoadedResources({ force: false }); } /** @@ -3909,7 +3983,6 @@ export class InteractiveMode { this.rebuildChatFromMessages(); dismissLoader(this.editor as Component); this.showLoadedResources({ - extensionPaths: runner?.getExtensionPaths() ?? [], force: false, showDiagnosticsWhenQuiet: true, }); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index f83894f1..498c3131 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -6,6 +6,7 @@ import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; import { highlight, supportsLanguage } from "cli-highlight"; import { getCustomThemesDir, getThemesDir } from "../../../config.js"; +import type { SourceInfo } from "../../../core/source-info.js"; // ============================================================================ // Types & Schema @@ -341,6 +342,7 @@ function resolveThemeColors>( export class Theme { readonly name?: string; readonly sourcePath?: string; + sourceInfo?: SourceInfo; private fgColors: Map; private bgColors: Map; private mode: ColorMode; @@ -349,10 +351,11 @@ export class Theme { fgColors: Record, bgColors: Record, mode: ColorMode, - options: { name?: string; sourcePath?: string } = {}, + options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { this.name = options.name; this.sourcePath = options.sourcePath; + this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f3a0c8b0..4e9c127e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -545,12 +545,12 @@ export async function runRpcMode(session: AgentSession): Promise { const commands: RpcSlashCommand[] = []; // Extension commands - for (const { command, extensionPath } of session.extensionRunner?.getRegisteredCommandsWithPaths() ?? []) { + for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) { commands.push({ name: command.name, description: command.description, source: "extension", - path: extensionPath, + path: command.extensionPath, }); } diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 1b265a1d..3c36cc8b 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -370,32 +370,33 @@ describe("ExtensionRunner", () => { expect(missing).toBeUndefined(); }); - it("filters out commands conflict with reseved", async () => { - const cmdCode = (name: string) => ` + it("filters out duplicate extension commands", async () => { + const cmdCode = (description: string) => ` export default function(pi) { - pi.registerCommand("${name}", { - description: "Test command", + pi.registerCommand("shared-cmd", { + description: "${description}", handler: async () => {}, }); } `; - fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); - fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); + fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("First command")); + fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("Second command")); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const commands = runner.getRegisteredCommands(new Set(["cmd-a"])); + const commands = runner.getRegisteredCommands(); const diagnostics = runner.getCommandDiagnostics(); - expect(commands.length).toBe(1); - expect(commands.map((c) => c.name).sort()).toEqual(["cmd-b"]); + expect(commands).toHaveLength(1); + expect(commands[0]?.name).toBe("shared-cmd"); + expect(commands[0]?.description).toBe("First command"); - expect(diagnostics.length).toBe(1); - expect(diagnostics[0].path).toEqual(path.join(extensionsDir, "cmd-a.ts")); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.path).toEqual(path.join(extensionsDir, "cmd-b.ts")); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in command")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with")); warnSpy.mockRestore(); }); }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 8d918543..f36abe69 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -346,14 +346,16 @@ Extra prompt content`, }); const { skills } = loader.getSkills(); - expect(skills.some((skill) => skill.name === "extra-skill")).toBe(true); + const loadedSkill = skills.find((skill) => skill.name === "extra-skill"); + expect(loadedSkill).toBeDefined(); + expect(loadedSkill?.sourceInfo?.source).toBe("extension:extra"); + expect(loadedSkill?.sourceInfo?.path).toBe(skillPath); const { prompts } = loader.getPrompts(); - expect(prompts.some((prompt) => prompt.name === "extra")).toBe(true); - - const metadata = loader.getPathMetadata(); - expect(metadata.get(skillPath)?.source).toBe("extension:extra"); - expect(metadata.get(promptPath)?.source).toBe("extension:extra"); + const loadedPrompt = prompts.find((prompt) => prompt.name === "extra"); + expect(loadedPrompt).toBeDefined(); + expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra"); + expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath); }); }); diff --git a/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts b/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts index 731bf54c..9918ccf6 100644 --- a/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts +++ b/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts @@ -156,7 +156,6 @@ function createMinimalResourceLoader(systemPrompt: string): ResourceLoader { getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], - getPathMetadata: () => new Map(), extendResources: () => {}, reload: async () => {}, }; diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index fa38cb74..1ceec315 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -58,7 +58,6 @@ This is a test skill. getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => undefined, getAppendSystemPrompt: () => [], - getPathMetadata: () => new Map(), extendResources: () => {}, reload: async () => {}, }; @@ -92,7 +91,6 @@ This is a test skill. getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => undefined, getAppendSystemPrompt: () => [], - getPathMetadata: () => new Map(), extendResources: () => {}, reload: async () => {}, }; diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 18e90f8c..fdefc26b 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -184,7 +184,6 @@ export function createTestResourceLoader(): ResourceLoader { getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => undefined, getAppendSystemPrompt: () => [], - getPathMetadata: () => new Map(), extendResources: () => {}, reload: async () => {}, }; diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 02bd4cb4..12a0e7c7 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -458,7 +458,6 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], - getPathMetadata: () => new Map(), extendResources: () => {}, reload: async () => {}, };