fix(coding-agent): disambiguate compact extension labels

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

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