fix(coding-agent): compact startup header and resource listings (#3267)

Use ctrl+o expansion for startup help and loaded resources, with compact comma-separated collapsed lists and full expanded context paths.

fixes #3147
This commit is contained in:
Armin Ronacher
2026-04-16 21:37:20 +02:00
committed by GitHub
parent 3b575e3ffb
commit f822408c77
2 changed files with 244 additions and 18 deletions

View File

@@ -126,6 +126,22 @@ function isExpandable(obj: unknown): obj is Expandable {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
} }
class ExpandableText extends Text implements Expandable {
constructor(
private readonly getCollapsedText: () => string,
private readonly getExpandedText: () => string,
expanded = false,
paddingX = 0,
paddingY = 0,
) {
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
}
setExpanded(expanded: boolean): void {
this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
}
}
type CompactionQueuedMessage = { type CompactionQueuedMessage = {
text: string; text: string;
mode: "steer" | "followUp"; mode: "steer" | "followUp";
@@ -499,7 +515,7 @@ export class InteractiveMode {
// Build startup instructions using keybinding hint helpers // Build startup instructions using keybinding hint helpers
const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description); const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
const instructions = [ const expandedInstructions = [
hint("app.interrupt", "to interrupt"), hint("app.interrupt", "to interrupt"),
hint("app.clear", "to clear"), hint("app.clear", "to clear"),
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
@@ -520,11 +536,28 @@ export class InteractiveMode {
hint("app.clipboard.pasteImage", "to paste image"), hint("app.clipboard.pasteImage", "to paste image"),
rawKeyHint("drop files", "to attach"), rawKeyHint("drop files", "to attach"),
].join("\n"); ].join("\n");
const compactInstructions = [
hint("app.interrupt", "interrupt"),
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
rawKeyHint("/", "commands"),
rawKeyHint("!", "bash"),
hint("app.tools.expand", "more"),
].join(theme.fg("muted", " · "));
const compactOnboarding = theme.fg(
"dim",
`Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`,
);
const onboarding = theme.fg( const onboarding = theme.fg(
"dim", "dim",
`Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`, `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`,
); );
this.builtInHeader = new Text(`${logo}\n${instructions}\n\n${onboarding}`, 1, 0); this.builtInHeader = new ExpandableText(
() => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`,
() => `${logo}\n${expandedInstructions}\n\n${onboarding}`,
this.toolOutputExpanded,
1,
0,
);
// Setup UI layout // Setup UI layout
this.headerContainer.addChild(new Spacer(1)); this.headerContainer.addChild(new Spacer(1));
@@ -836,6 +869,23 @@ export class InteractiveMode {
return result; return result;
} }
private formatContextPath(p: string): string {
const cwd = path.resolve(this.sessionManager.getCwd());
const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p);
const relativePath = path.relative(cwd, absolutePath);
const isInsideCwd =
relativePath === "" ||
(!relativePath.startsWith("..") &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath));
if (isInsideCwd) {
return relativePath || ".";
}
return this.formatDisplayPath(absolutePath);
}
/** /**
* Get a short path relative to the package root for display. * Get a short path relative to the package root for display.
*/ */
@@ -1057,6 +1107,38 @@ export class InteractiveMode {
} }
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); 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) {
labels.sort((a, b) => a.localeCompare(b));
}
return theme.fg("dim", ` ${labels.join(", ")}`);
};
const addLoadedSection = (
name: string,
collapsedBody: string,
expandedBody = collapsedBody,
color: ThemeColor = "mdHeading",
): void => {
const section = new ExpandableText(
() => `${sectionHeader(name, color)}\n${collapsedBody}`,
() => `${sectionHeader(name, color)}\n${expandedBody}`,
this.toolOutputExpanded,
0,
0,
);
this.chatContainer.addChild(section);
this.chatContainer.addChild(new Spacer(1));
};
const skillsResult = this.session.resourceLoader.getSkills(); const skillsResult = this.session.resourceLoader.getSkills();
const promptsResult = this.session.resourceLoader.getPrompts(); const promptsResult = this.session.resourceLoader.getPrompts();
@@ -1096,8 +1178,11 @@ export class InteractiveMode {
const contextList = contextFiles const contextList = contextFiles
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
.join("\n"); .join("\n");
this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0)); const contextCompactList = formatCompactList(
this.chatContainer.addChild(new Spacer(1)); contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),
{ sort: false },
);
addLoadedSection("Context", contextCompactList, contextList);
} }
const skills = skillsResult.skills; const skills = skillsResult.skills;
@@ -1109,8 +1194,8 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path), formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); const skillCompactList = formatCompactList(skills.map((skill) => skill.name));
this.chatContainer.addChild(new Spacer(1)); addLoadedSection("Skills", skillCompactList, skillList);
} }
const templates = this.session.promptTemplates; const templates = this.session.promptTemplates;
@@ -1129,8 +1214,8 @@ export class InteractiveMode {
return template ? `/${template.name}` : this.formatDisplayPath(item.path); return template ? `/${template.name}` : this.formatDisplayPath(item.path);
}, },
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0)); const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));
this.chatContainer.addChild(new Spacer(1)); addLoadedSection("Prompts", promptCompactList, templateList);
} }
if (extensions.length > 0) { if (extensions.length > 0) {
@@ -1139,8 +1224,10 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path), formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0)); const extensionCompactList = formatCompactList(
this.chatContainer.addChild(new Spacer(1)); extensions.map((extension) => compactPath(extension.path, extension.sourceInfo)),
);
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
} }
// Show loaded themes (excluding built-in) // Show loaded themes (excluding built-in)
@@ -1157,8 +1244,12 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path), formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0)); const themeCompactList = formatCompactList(
this.chatContainer.addChild(new Spacer(1)); customThemes.map(
(loadedTheme) => loadedTheme.name ?? compactPath(loadedTheme.sourcePath!, loadedTheme.sourceInfo),
),
);
addLoadedSection("Themes", themeCompactList, themeList);
} }
} }
@@ -1607,6 +1698,9 @@ export class InteractiveMode {
if (factory) { if (factory) {
// Create and add custom header // Create and add custom header
this.customHeader = factory(this.ui, theme); this.customHeader = factory(this.ui, theme);
if (isExpandable(this.customHeader)) {
this.customHeader.setExpanded(this.toolOutputExpanded);
}
if (index !== -1) { if (index !== -1) {
this.headerContainer.children[index] = this.customHeader; this.headerContainer.children[index] = this.customHeader;
} else { } else {
@@ -1616,6 +1710,9 @@ export class InteractiveMode {
} else { } else {
// Restore built-in header // Restore built-in header
this.customHeader = undefined; this.customHeader = undefined;
if (isExpandable(this.builtInHeader)) {
this.builtInHeader.setExpanded(this.toolOutputExpanded);
}
if (index !== -1) { if (index !== -1) {
this.headerContainer.children[index] = this.builtInHeader; this.headerContainer.children[index] = this.builtInHeader;
} }
@@ -3080,6 +3177,10 @@ export class InteractiveMode {
private setToolsExpanded(expanded: boolean): void { private setToolsExpanded(expanded: boolean): void {
this.toolOutputExpanded = expanded; this.toolOutputExpanded = expanded;
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
activeHeader.setExpanded(expanded);
}
for (const child of this.chatContainer.children) { for (const child of this.chatContainer.children) {
if (isExpandable(child)) { if (isExpandable(child)) {
child.setExpanded(expanded); child.setExpanded(expanded);
@@ -4152,6 +4253,10 @@ export class InteractiveMode {
try { try {
await this.session.reload(); await this.session.reload();
this.keybindings.reload(); this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes); setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
const themeName = this.settingsManager.getTheme(); const themeName = this.settingsManager.getTheme();

View File

@@ -1,3 +1,5 @@
import { homedir } from "node:os";
import * as path from "node:path";
import { Container } from "@mariozechner/pi-tui"; import { Container } from "@mariozechner/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest"; import { beforeAll, describe, expect, test, vi } from "vitest";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
@@ -60,6 +62,27 @@ describe("InteractiveMode.showStatus", () => {
}); });
}); });
describe("InteractiveMode.setToolsExpanded", () => {
test("applies expansion state to the active header and chat entries", () => {
const header = { setExpanded: vi.fn() };
const chatChild = { setExpanded: vi.fn() };
const fakeThis: any = {
toolOutputExpanded: false,
customHeader: undefined,
builtInHeader: header,
chatContainer: { children: [chatChild] },
ui: { requestRender: vi.fn() },
};
(InteractiveMode as any).prototype.setToolsExpanded.call(fakeThis, true);
expect(fakeThis.toolOutputExpanded).toBe(true);
expect(header.setExpanded).toHaveBeenCalledWith(true);
expect(chatChild.setExpanded).toHaveBeenCalledWith(true);
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
});
});
describe("InteractiveMode.createExtensionUIContext setTheme", () => { describe("InteractiveMode.createExtensionUIContext setTheme", () => {
test("persists theme changes to settings manager", () => { test("persists theme changes to settings manager", () => {
initTheme("dark"); initTheme("dark");
@@ -116,31 +139,40 @@ describe("InteractiveMode.showLoadedResources", () => {
function createShowLoadedResourcesThis(options: { function createShowLoadedResourcesThis(options: {
quietStartup: boolean; quietStartup: boolean;
verbose?: boolean; verbose?: boolean;
skills?: Array<{ filePath: string }>; toolOutputExpanded?: boolean;
cwd?: string;
contextFiles?: Array<{ path: string; content?: string }>;
extensions?: Array<{ path: string }>;
skills?: Array<{ filePath: string; name: string }>;
skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>; skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>;
}) { }) {
const fakeThis: any = { const fakeThis: any = {
options: { verbose: options.verbose ?? false }, options: { verbose: options.verbose ?? false },
toolOutputExpanded: options.toolOutputExpanded ?? false,
chatContainer: new Container(), chatContainer: new Container(),
settingsManager: { settingsManager: {
getQuietStartup: () => options.quietStartup, getQuietStartup: () => options.quietStartup,
}, },
sessionManager: {
getCwd: () => options.cwd ?? "/tmp/project",
},
session: { session: {
promptTemplates: [], promptTemplates: [],
extensionRunner: undefined, extensionRunner: undefined,
resourceLoader: { resourceLoader: {
getPathMetadata: () => new Map(), getPathMetadata: () => new Map(),
getAgentsFiles: () => ({ agentsFiles: [] }), getAgentsFiles: () => ({ agentsFiles: options.contextFiles ?? [] }),
getSkills: () => ({ getSkills: () => ({
skills: options.skills ?? [], skills: options.skills ?? [],
diagnostics: options.skillDiagnostics ?? [], diagnostics: options.skillDiagnostics ?? [],
}), }),
getPrompts: () => ({ prompts: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
getExtensions: () => ({ extensions: [], errors: [], runtime: {} }), getExtensions: () => ({ extensions: options.extensions ?? [], errors: [], runtime: {} }),
getThemes: () => ({ themes: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }),
}, },
}, },
formatDisplayPath: (p: string) => p, formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p),
formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p),
buildScopeGroups: () => [], buildScopeGroups: () => [],
formatScopeGroups: () => "resource-list", formatScopeGroups: () => "resource-list",
getShortPath: (p: string) => p, getShortPath: (p: string) => p,
@@ -151,10 +183,99 @@ describe("InteractiveMode.showLoadedResources", () => {
return fakeThis; return fakeThis;
} }
test("shows a compact resource listing by default", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("commit");
expect(output).not.toContain("resource-list");
});
test("shows full resource listing when expanded", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
toolOutputExpanded: true,
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
test("abbreviates extensions in compact listing", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Extensions]");
expect(output).toContain("answer.ts, btw.ts");
expect(output).not.toContain("extensions/answer.ts");
});
test("shows context paths relative to cwd while preserving full external paths", () => {
const home = homedir();
const cwd = path.join(home, "Development", "pi-mono");
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
cwd,
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
});
test("shows full context paths when expanded", () => {
const home = homedir();
const cwd = path.join(home, "Development", "pi-mono");
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
toolOutputExpanded: true,
cwd,
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("~/.pi/agent/AGENTS.md");
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
});
test("does not show verbose listing on quiet startup during reload", () => { test("does not show verbose listing on quiet startup during reload", () => {
const fakeThis = createShowLoadedResourcesThis({ const fakeThis = createShowLoadedResourcesThis({
quietStartup: true, quietStartup: true,
skills: [{ filePath: "/tmp/skill/SKILL.md" }], skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
}); });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { (InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
@@ -169,7 +290,7 @@ describe("InteractiveMode.showLoadedResources", () => {
test("still shows diagnostics on quiet startup when requested", () => { test("still shows diagnostics on quiet startup when requested", () => {
const fakeThis = createShowLoadedResourcesThis({ const fakeThis = createShowLoadedResourcesThis({
quietStartup: true, quietStartup: true,
skills: [{ filePath: "/tmp/skill/SKILL.md" }], skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }], skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }],
}); });