fix(coding-agent): disambiguate compact extension labels
Some checks are pending
CI / build-check-test (push) Waiting to run

closes #3308
This commit is contained in:
Armin Ronacher 2026-04-17 09:46:47 +02:00
parent ddbf642179
commit 32a305cb9b
3 changed files with 359 additions and 15 deletions

View file

@ -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))

View file

@ -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);

View file

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