diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f3785e5..b7c41668 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed compact interactive extension startup summaries to disambiguate package extensions and repeated local `index.ts` entries by using package-aware labels and the minimal parent path needed to make local entries unique ([#3308](https://github.com/badlogic/pi-mono/issues/3308)) - Fixed git package dependency installation to use production installs (`npm install --omit=dev`) during both install and update flows, so extension runtime dependencies must come from `dependencies` and not `devDependencies` ([#3009](https://github.com/badlogic/pi-mono/issues/3009)) - Fixed `tool_result` / `afterToolCall` extension handling for error results by forwarding `details` and `isError` overrides through `AgentSession` instead of dropping them when `isError` was already true ([#3051](https://github.com/badlogic/pi-mono/issues/3051)) - Fixed missing root exports for `RpcClient` and RPC protocol types from `@mariozechner/pi-coding-agent`, so ESM consumers can import them from the main package entrypoint ([#3275](https://github.com/badlogic/pi-mono/issues/3275)) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d2ae01de..b8df40f2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -906,6 +906,20 @@ export class InteractiveMode { * Get a short path relative to the package root for display. */ private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string { + const baseDir = sourceInfo?.baseDir; + if (baseDir && this.isPackageSource(sourceInfo)) { + const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath)); + if ( + relativePath && + relativePath !== "." && + !relativePath.startsWith("..") && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ) { + return relativePath.replace(/\\/g, "/"); + } + } + const source = sourceInfo?.source ?? ""; const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/); if (npmMatch && source.startsWith("npm:")) { @@ -920,6 +934,108 @@ export class InteractiveMode { return this.formatDisplayPath(fullPath); } + private getCompactPathLabel(resourcePath: string, sourceInfo?: SourceInfo): string { + const shortPath = this.getShortPath(resourcePath, sourceInfo); + const normalizedPath = shortPath.replace(/\\/g, "/"); + const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~"); + if (segments.length > 0) { + return segments[segments.length - 1]!; + } + return shortPath; + } + + private getCompactPackageSourceLabel(sourceInfo?: SourceInfo): string { + const source = sourceInfo?.source ?? ""; + if (source.startsWith("npm:")) { + return source.slice("npm:".length) || source; + } + + const gitSource = parseGitUrl(source); + if (gitSource) { + return gitSource.path || source; + } + + return source; + } + + private getCompactExtensionLabel(resourcePath: string, sourceInfo?: SourceInfo): string { + if (!this.isPackageSource(sourceInfo)) { + return this.getCompactPathLabel(resourcePath, sourceInfo); + } + + const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo); + if (!sourceLabel) { + return this.getCompactPathLabel(resourcePath, sourceInfo); + } + + const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/"); + const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath; + const parsedPath = path.posix.parse(packagePath); + + if (parsedPath.name === "index") { + return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`; + } + + return `${sourceLabel}:${packagePath}`; + } + + private getCompactDisplayPathSegments(resourcePath: string): string[] { + return this.formatDisplayPath(resourcePath) + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "~"); + } + + private getCompactNonPackageExtensionLabel( + resourcePath: string, + index: number, + allPaths: Array<{ path: string; segments: string[] }>, + ): string { + const segments = allPaths[index]?.segments; + if (!segments || segments.length === 0) { + return this.getCompactPathLabel(resourcePath); + } + + for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) { + const candidate = segments.slice(-segmentCount).join("/"); + const isUnique = allPaths.every((item, itemIndex) => { + if (itemIndex === index) { + return true; + } + return item.segments.slice(-segmentCount).join("/") !== candidate; + }); + + if (isUnique) { + return candidate; + } + } + + return segments.join("/"); + } + + private getCompactExtensionLabels(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): string[] { + const nonPackageExtensions = extensions + .map((extension) => ({ + path: extension.path, + sourceInfo: extension.sourceInfo, + segments: this.getCompactDisplayPathSegments(extension.path), + })) + .filter((extension) => !this.isPackageSource(extension.sourceInfo)); + + return extensions.map((extension) => { + if (this.isPackageSource(extension.sourceInfo)) { + return this.getCompactExtensionLabel(extension.path, extension.sourceInfo); + } + + const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path); + if (nonPackageIndex === -1) { + return this.getCompactPathLabel(extension.path, extension.sourceInfo); + } + + return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions); + }); + } + private getDisplaySourceInfo(sourceInfo?: SourceInfo): { label: string; scopeLabel?: string; @@ -1123,15 +1239,6 @@ export class InteractiveMode { } const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); - const compactPath = (resourcePath: string, sourceInfo?: SourceInfo): string => { - const shortPath = this.getShortPath(resourcePath, sourceInfo); - const normalizedPath = shortPath.replace(/\\/g, "/"); - const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~"); - if (segments.length > 0) { - return segments[segments.length - 1]!; - } - return shortPath; - }; const formatCompactList = (items: string[], options?: { sort?: boolean }): string => { const labels = items.map((item) => item.trim()).filter((item) => item.length > 0); if (options?.sort !== false) { @@ -1240,9 +1347,7 @@ export class InteractiveMode { formatPath: (item) => this.formatDisplayPath(item.path), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), }); - const extensionCompactList = formatCompactList( - extensions.map((extension) => compactPath(extension.path, extension.sourceInfo)), - ); + const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions)); addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading"); } @@ -1262,7 +1367,8 @@ export class InteractiveMode { }); const themeCompactList = formatCompactList( customThemes.map( - (loadedTheme) => loadedTheme.name ?? compactPath(loadedTheme.sourcePath!, loadedTheme.sourceInfo), + (loadedTheme) => + loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath!, loadedTheme.sourceInfo), ), ); addLoadedSection("Themes", themeCompactList, themeList); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index e2af115b..8e8ff6de 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,6 +2,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Container } from "@mariozechner/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { SourceInfo } from "../src/core/source-info.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; @@ -15,6 +16,21 @@ function renderAll(container: Container, width = 120): string { return container.children.flatMap((child) => child.render(width)).join("\n"); } +function normalizeRenderedOutput(container: Container, width = 220): string { + return renderAll(container, width) + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\\/g, "/") + .split("\n") + .map((line) => line.replace(/\s+$/g, "")) + .join("\n") + .trim(); +} + +type ExtensionFixture = { + path: string; + sourceInfo?: SourceInfo; +}; + describe("InteractiveMode.showStatus", () => { beforeAll(() => { // showStatus uses the global theme instance @@ -142,9 +158,10 @@ describe("InteractiveMode.showLoadedResources", () => { toolOutputExpanded?: boolean; cwd?: string; contextFiles?: Array<{ path: string; content?: string }>; - extensions?: Array<{ path: string }>; + extensions?: ExtensionFixture[]; skills?: Array<{ filePath: string; name: string }>; skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>; + useRealScopeGroups?: boolean; }) { const fakeThis: any = { options: { verbose: options.verbose ?? false }, @@ -176,14 +193,142 @@ describe("InteractiveMode.showLoadedResources", () => { getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis), buildScopeGroups: () => [], formatScopeGroups: () => "resource-list", - getShortPath: (p: string) => p, + isPackageSource: (sourceInfo?: SourceInfo) => + (InteractiveMode as any).prototype.isPackageSource.call(fakeThis, sourceInfo), + getShortPath: (p: string, sourceInfo?: SourceInfo) => + (InteractiveMode as any).prototype.getShortPath.call(fakeThis, p, sourceInfo), + getCompactPathLabel: (p: string, sourceInfo?: SourceInfo) => + (InteractiveMode as any).prototype.getCompactPathLabel.call(fakeThis, p, sourceInfo), + getCompactPackageSourceLabel: (sourceInfo?: SourceInfo) => + (InteractiveMode as any).prototype.getCompactPackageSourceLabel.call(fakeThis, sourceInfo), + getCompactExtensionLabel: (p: string, sourceInfo?: SourceInfo) => + (InteractiveMode as any).prototype.getCompactExtensionLabel.call(fakeThis, p, sourceInfo), + getCompactDisplayPathSegments: (p: string) => + (InteractiveMode as any).prototype.getCompactDisplayPathSegments.call(fakeThis, p), + getCompactNonPackageExtensionLabel: ( + p: string, + index: number, + allPaths: Array<{ path: string; segments: string[] }>, + ) => (InteractiveMode as any).prototype.getCompactNonPackageExtensionLabel.call(fakeThis, p, index, allPaths), + getCompactExtensionLabels: (extensions: ExtensionFixture[]) => + (InteractiveMode as any).prototype.getCompactExtensionLabels.call(fakeThis, extensions), formatDiagnostics: () => "diagnostics", getBuiltInCommandConflictDiagnostics: () => [], }; + if (options.useRealScopeGroups) { + fakeThis.getScopeGroup = (sourceInfo?: SourceInfo) => + (InteractiveMode as any).prototype.getScopeGroup.call(fakeThis, sourceInfo); + fakeThis.buildScopeGroups = (items: Array<{ path: string; sourceInfo?: SourceInfo }>) => + (InteractiveMode as any).prototype.buildScopeGroups.call(fakeThis, items); + fakeThis.formatScopeGroups = (groups: unknown, formatOptions: unknown) => + (InteractiveMode as any).prototype.formatScopeGroups.call(fakeThis, groups, formatOptions); + } + return fakeThis; } + function createSourceInfo( + filePath: string, + options: { + source: string; + scope: "user" | "project" | "temporary"; + origin: "package" | "top-level"; + baseDir?: string; + }, + ): SourceInfo { + return { + path: filePath, + source: options.source, + scope: options.scope, + origin: options.origin, + baseDir: options.baseDir, + }; + } + + function createExtensionFixtures(): ExtensionFixture[] { + return [ + { + path: "/tmp/project/.pi/extensions/answer.ts", + sourceInfo: createSourceInfo("/tmp/project/.pi/extensions/answer.ts", { + source: "local", + scope: "project", + origin: "top-level", + baseDir: "/tmp/project/.pi/extensions", + }), + }, + { + path: "/tmp/project/.pi/extensions/local-index/index.ts", + sourceInfo: createSourceInfo("/tmp/project/.pi/extensions/local-index/index.ts", { + source: "local", + scope: "project", + origin: "top-level", + baseDir: "/tmp/project/.pi/extensions", + }), + }, + { + path: "/tmp/agent/extensions/user-index/index.ts", + sourceInfo: createSourceInfo("/tmp/agent/extensions/user-index/index.ts", { + source: "local", + scope: "user", + origin: "top-level", + baseDir: "/tmp/agent/extensions", + }), + }, + { + path: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", + sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", { + source: "npm:pi-markdown-preview", + scope: "project", + origin: "package", + baseDir: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview", + }), + }, + { + path: "/tmp/project/.pi/npm/node_modules/@scope/pi-scoped/extensions/index.ts", + sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/@scope/pi-scoped/extensions/index.ts", { + source: "npm:@scope/pi-scoped", + scope: "project", + origin: "package", + baseDir: "/tmp/project/.pi/npm/node_modules/@scope/pi-scoped", + }), + }, + { + path: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/index.ts", + sourceInfo: createSourceInfo( + "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/index.ts", + { + source: "git:github.com/HazAT/pi-interactive-subagents", + scope: "project", + origin: "package", + baseDir: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents", + }, + ), + }, + { + path: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/subagents/index.ts", + sourceInfo: createSourceInfo( + "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/subagents/index.ts", + { + source: "git:github.com/HazAT/pi-interactive-subagents", + scope: "project", + origin: "package", + baseDir: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents", + }, + ), + }, + { + path: "/tmp/temp/cli-extension.ts", + sourceInfo: createSourceInfo("/tmp/temp/cli-extension.ts", { + source: "cli", + scope: "temporary", + origin: "top-level", + baseDir: "/tmp/temp", + }), + }, + ]; + } + test("shows a compact resource listing by default", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, @@ -251,6 +396,98 @@ describe("InteractiveMode.showLoadedResources", () => { expect(output).not.toContain("extensions/answer.ts"); }); + test("captures mixed extension layouts in compact output", () => { + const fakeThis = createShowLoadedResourcesThis({ + quietStartup: false, + extensions: createExtensionFixtures(), + useRealScopeGroups: true, + }); + + (InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { + force: false, + }); + + expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(` +"[Extensions] + @scope/pi-scoped, answer.ts, cli-extension.ts, HazAT/pi-interactive-subagents, HazAT/pi-interactive-subagents:subagents, local-index/index.ts, pi-markdown-preview, user-index/index.ts"`); + }); + + test("adds more parent folders until local extension labels are unique", () => { + const extensions: ExtensionFixture[] = [ + { + path: "/tmp/alpha/one/index.ts", + sourceInfo: createSourceInfo("/tmp/alpha/one/index.ts", { + source: "cli", + scope: "temporary", + origin: "top-level", + baseDir: "/tmp/alpha", + }), + }, + { + path: "/tmp/beta/one/index.ts", + sourceInfo: createSourceInfo("/tmp/beta/one/index.ts", { + source: "cli", + scope: "temporary", + origin: "top-level", + baseDir: "/tmp/beta", + }), + }, + { + path: "/tmp/gamma/one/index.ts", + sourceInfo: createSourceInfo("/tmp/gamma/one/index.ts", { + source: "cli", + scope: "temporary", + origin: "top-level", + baseDir: "/tmp/gamma", + }), + }, + ]; + + const fakeThis = createShowLoadedResourcesThis({ + quietStartup: false, + extensions, + useRealScopeGroups: true, + }); + + (InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { + force: false, + }); + + expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(` +"[Extensions] + alpha/one/index.ts, beta/one/index.ts, gamma/one/index.ts"`); + }); + + test("captures mixed extension layouts in expanded output", () => { + const fakeThis = createShowLoadedResourcesThis({ + quietStartup: false, + toolOutputExpanded: true, + extensions: createExtensionFixtures(), + useRealScopeGroups: true, + }); + + (InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { + force: false, + }); + + expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(` +"[Extensions] + project + /tmp/project/.pi/extensions/answer.ts + /tmp/project/.pi/extensions/local-index/index.ts + git:github.com/HazAT/pi-interactive-subagents + extensions/index.ts + extensions/subagents/index.ts + npm:@scope/pi-scoped + extensions/index.ts + npm:pi-markdown-preview + extensions/index.ts + user + /tmp/agent/extensions/user-index/index.ts + path + /tmp/temp/cli-extension.ts"`); + }); + test("shows context paths relative to cwd while preserving full external paths", () => { const home = homedir(); const cwd = path.join(home, "Development", "pi-mono");