mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-05-27 00:25:37 +00:00
fix(coding-agent): disambiguate compact extension labels
Some checks are pending
CI / build-check-test (push) Waiting to run
Some checks are pending
CI / build-check-test (push) Waiting to run
closes #3308
This commit is contained in:
parent
ddbf642179
commit
32a305cb9b
3 changed files with 359 additions and 15 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue