Improve project-local pi config (#6309)

* feat(coding-agent): improve config resource overrides

* fix(coding-agent): simplify config resource overrides
This commit is contained in:
Armin Ronacher
2026-07-06 20:50:30 +02:00
committed by GitHub
parent 8c0ccd14b3
commit c8ada4e76e
9 changed files with 599 additions and 81 deletions
@@ -18,11 +18,18 @@ import {
import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { rawKeyHint } from "./keybinding-hints.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
type ConfigWriteScope = "global" | "project";
type SettingsScope = "user" | "project";
type ProjectOverrideState = "inherit" | "load" | "unload";
export type ScopedResolvedPaths = Record<ConfigWriteScope, ResolvedPaths>;
const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceType[];
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
extensions: "Extensions",
@@ -178,25 +185,42 @@ type FlatEntry =
| { type: "item"; item: ResourceItem };
class ConfigSelectorHeader implements Component {
private writeScope: ConfigWriteScope;
private projectModeAvailable: boolean;
constructor(writeScope: ConfigWriteScope, projectModeAvailable: boolean) {
this.writeScope = writeScope;
this.projectModeAvailable = projectModeAvailable;
}
setWriteScope(writeScope: ConfigWriteScope): void {
this.writeScope = writeScope;
}
invalidate(): void {}
render(width: number): string[] {
const title = theme.bold("Resource Configuration");
const title = theme.bold(this.writeScope === "project" ? "Project Local Resources" : "Global Resources");
const sep = theme.fg("muted", " · ");
const hint = rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close");
const hintWidth = visibleWidth(hint);
const titleWidth = visibleWidth(title);
const spacing = Math.max(1, width - titleWidth - hintWidth);
const switchHint = this.projectModeAvailable ? keyHint("tui.input.tab", "switch mode") + sep : "";
const actionHint =
this.writeScope === "project" ? rawKeyHint("space", "cycle inherit/+/-") : rawKeyHint("space", "toggle");
const hint = switchHint + actionHint + sep + rawKeyHint("esc", "close");
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
const scopeHint =
this.writeScope === "project"
? theme.fg("muted", `${CONFIG_DIR_NAME}/settings.json · inherited global resources are dimmed`)
: theme.fg("muted", `~/${CONFIG_DIR_NAME}/agent/settings.json`);
return [
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
theme.fg("muted", "Type to filter resources"),
truncateToWidth(scopeHint, width, ""),
];
}
}
class ResourceList implements Component, Focusable {
private groups: ResourceGroup[];
private groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>;
private flatItems: FlatEntry[] = [];
private filteredItems: FlatEntry[] = [];
private selectedIndex = 0;
@@ -205,10 +229,13 @@ class ResourceList implements Component, Focusable {
private settingsManager: SettingsManager;
private cwd: string;
private agentDir: string;
private writeScope: ConfigWriteScope;
private inheritedEnabledByKey: Map<string, boolean>;
public onCancel?: () => void;
public onExit?: () => void;
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
public onSwitchMode?: () => void;
private _focused = false;
get focused(): boolean {
@@ -220,16 +247,19 @@ class ResourceList implements Component, Focusable {
}
constructor(
groups: ResourceGroup[],
groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
) {
this.groups = groups;
this.groupsByScope = groupsByScope;
this.settingsManager = settingsManager;
this.cwd = cwd;
this.agentDir = agentDir;
this.writeScope = writeScope;
this.inheritedEnabledByKey = this.buildInheritedEnabledMap(groupsByScope.global);
this.searchInput = new Input();
// 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border
const chrome = 8;
@@ -238,6 +268,28 @@ class ResourceList implements Component, Focusable {
this.filteredItems = [...this.flatItems];
}
setWriteScope(writeScope: ConfigWriteScope): void {
this.writeScope = writeScope;
this.buildFlatList();
this.filterItems(this.searchInput.getValue());
}
private get groups(): ResourceGroup[] {
return this.groupsByScope[this.writeScope];
}
private buildInheritedEnabledMap(groups: ResourceGroup[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const group of groups) {
for (const subgroup of group.subgroups) {
for (const item of subgroup.items) {
result.set(this.getResourceItemKey(item), item.enabled);
}
}
}
return result;
}
private buildFlatList(): void {
this.flatItems = [];
for (const group of this.groups) {
@@ -362,19 +414,29 @@ class ResourceList implements Component, Focusable {
if (entry.type === "group") {
// Main group header (no cursor)
const groupLine = theme.fg("accent", theme.bold(entry.group.label));
const inherited = this.writeScope === "project" && entry.group.scope === "user";
const label = theme.bold(`${entry.group.label}${inherited ? " · inherited global" : ""}`);
const groupLine = theme.fg(inherited ? "dim" : "accent", label);
lines.push(truncateToWidth(` ${groupLine}`, width, ""));
} else if (entry.type === "subgroup") {
// Subgroup header (indented, no cursor)
const subgroupLine = theme.fg("muted", entry.subgroup.label);
const color = this.writeScope === "project" && entry.group.scope === "user" ? "dim" : "muted";
const subgroupLine = theme.fg(color, entry.subgroup.label);
lines.push(truncateToWidth(` ${subgroupLine}`, width, ""));
} else {
// Resource item (cursor only on items)
const item = entry.item;
const cursor = isSelected ? "> " : " ";
const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
const name = isSelected ? theme.bold(item.displayName) : item.displayName;
lines.push(truncateToWidth(`${cursor} ${checkbox} ${name}`, width, "..."));
const dimmed = this.isDimmedItem(item);
const nameText = isSelected && !dimmed ? theme.bold(item.displayName) : item.displayName;
const name = dimmed ? theme.fg("dim", nameText) : nameText;
lines.push(
truncateToWidth(
`${cursor} ${this.renderCheckbox(item)} ${name}${this.getItemSuffix(item)}`,
width,
"...",
),
);
}
}
@@ -430,13 +492,18 @@ class ResourceList implements Component, Focusable {
this.onExit?.();
return;
}
if (kb.matches(data, "tui.input.tab")) {
this.onSwitchMode?.();
return;
}
if (data === " " || kb.matches(data, "tui.select.confirm")) {
const entry = this.filteredItems[this.selectedIndex];
if (entry?.type === "item") {
const newEnabled = !entry.item.enabled;
this.toggleResource(entry.item, newEnabled);
this.updateItem(entry.item, newEnabled);
this.onToggle?.(entry.item, newEnabled);
if (entry?.type === "item" && (this.writeScope === "project" || this.getItemScope(entry.item) === "user")) {
const newEnabled = this.toggleResource(entry.item);
if (newEnabled !== undefined) {
this.updateItem(entry.item, newEnabled);
this.onToggle?.(entry.item, newEnabled);
}
}
return;
}
@@ -446,12 +513,20 @@ class ResourceList implements Component, Focusable {
this.filterItems(this.searchInput.getValue());
}
private toggleResource(item: ResourceItem, enabled: boolean): void {
private toggleResource(item: ResourceItem): boolean | undefined {
if (this.writeScope === "project") {
const state = this.getNextOverrideState(item);
if (!this.setProjectResourceOverride(item, state)) return undefined;
return state === "inherit" ? this.getInheritedEnabled(item) : state === "load";
}
const enabled = !item.enabled;
if (item.metadata.origin === "top-level") {
this.toggleTopLevelResource(item, enabled);
} else {
this.togglePackageResource(item, enabled);
}
return enabled;
}
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
@@ -561,6 +636,217 @@ class ResourceList implements Component, Focusable {
}
}
private renderCheckbox(item: ResourceItem): string {
if (this.writeScope === "project") {
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("success", "[+]");
if (state === "unload") return theme.fg("warning", "[-]");
return theme.fg("dim", item.enabled ? "[x]" : "[ ]");
}
return item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
}
private getItemSuffix(item: ResourceItem): string {
if (this.writeScope !== "project") return "";
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("muted", " project load");
if (state === "unload") return theme.fg("muted", " project unload");
return this.isInheritedGlobalItem(item) ? theme.fg("dim", " inherited global") : "";
}
private isDimmedItem(item: ResourceItem): boolean {
return (
this.writeScope === "project" &&
this.isInheritedGlobalItem(item) &&
this.getProjectOverrideState(item) === "inherit"
);
}
private setProjectResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
return item.metadata.origin === "top-level"
? this.setProjectTopLevelOverride(item, state)
: this.setProjectPackageOverride(item, state);
}
private setProjectTopLevelOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const current = (this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[];
const pattern = this.isInheritedGlobalItem(item) ? item.path : this.getResourcePatternForScope(item, "project");
const patterns = this.getTopLevelOverridePatterns(item, "project");
const updated = current.filter((entry) => {
const target = this.getPatternEntryTarget(entry);
if ((entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) && patterns.has(target))
return false;
return !(state === "inherit" && this.isInheritedGlobalItem(item) && target === pattern);
});
if (state !== "inherit") {
if (this.isInheritedGlobalItem(item) && !updated.includes(pattern)) updated.push(pattern);
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
}
this.setProjectTopLevelPaths(item.resourceType, updated);
return true;
}
private setProjectTopLevelPaths(key: ResourceType, paths: string[]): void {
if (key === "extensions") this.settingsManager.setProjectExtensionPaths(paths);
else if (key === "skills") this.settingsManager.setProjectSkillPaths(paths);
else if (key === "prompts") this.settingsManager.setProjectPromptTemplatePaths(paths);
else this.settingsManager.setProjectThemePaths(paths);
}
private setProjectPackageOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const packages = [...(this.settingsManager.getProjectSettings().packages ?? [])] as PackageSource[];
let pkgIndex = packages.findIndex((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
"project",
),
);
if (pkgIndex === -1) {
if (state === "inherit") return false;
packages.push(this.createPackageOverrideSource(item));
pkgIndex = packages.length - 1;
}
let pkg = packages[pkgIndex];
if (pkg === undefined) return false;
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const pattern = this.getPackageResourcePattern(item);
const updated = ((pkg[item.resourceType] ?? []) as string[]).filter(
(entry) => this.getPatternEntryTarget(entry) !== pattern,
);
if (state !== "inherit") updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
(pkg as Record<string, unknown>)[item.resourceType] = updated.length > 0 ? updated : undefined;
if (!RESOURCE_TYPES.some((key) => (pkg as Record<string, unknown>)[key] !== undefined)) {
if (pkg.autoload === false) packages.splice(pkgIndex, 1);
else packages[pkgIndex] = pkg.source;
}
this.settingsManager.setProjectPackages(packages);
return true;
}
private getNextOverrideState(item: ResourceItem): ProjectOverrideState {
const state = this.getProjectOverrideState(item);
const inheritedEnabled = this.getInheritedEnabled(item);
if (state === "inherit") return inheritedEnabled ? "unload" : "load";
if (state === "unload") return inheritedEnabled ? "load" : "inherit";
return inheritedEnabled ? "inherit" : "unload";
}
private getProjectOverrideState(item: ResourceItem): ProjectOverrideState {
if (this.writeScope !== "project") return "inherit";
if (item.metadata.origin === "top-level") {
return this.getOverrideStateFromEntries(
(this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[],
this.getTopLevelOverridePatterns(item, "project"),
false,
);
}
const pkg = this.findMatchingPackageSource(item, "project");
if (typeof pkg !== "object") return "inherit";
const entries = pkg[item.resourceType];
if (entries === undefined) return "inherit";
return this.getOverrideStateFromEntries(
entries,
new Set([this.getPackageResourcePattern(item)]),
pkg.autoload !== false,
);
}
private getOverrideStateFromEntries(
entries: string[],
patterns: Set<string>,
emptyArrayIsUnload: boolean,
): ProjectOverrideState {
if (entries.length === 0 && emptyArrayIsUnload) return "unload";
let state: ProjectOverrideState = "inherit";
for (const entry of entries) {
if (!patterns.has(this.getPatternEntryTarget(entry))) continue;
if (entry.startsWith("!") || entry.startsWith("-")) state = "unload";
else state = "load";
}
return state;
}
private getInheritedEnabled(item: ResourceItem): boolean {
return (
this.inheritedEnabledByKey.get(this.getResourceItemKey(item)) ??
(this.getItemScope(item) === "user" ? item.enabled : true)
);
}
private isInheritedGlobalItem(item: ResourceItem): boolean {
return this.getItemScope(item) === "user" || this.inheritedEnabledByKey.has(this.getResourceItemKey(item));
}
private getTopLevelOverridePatterns(item: ResourceItem, scope: SettingsScope): Set<string> {
const baseDir = this.getTopLevelBaseDir(scope);
const patterns = new Set<string>([
this.getResourcePatternForScope(item, scope),
item.path,
relative(baseDir, item.path),
]);
if (item.metadata.baseDir) patterns.add(relative(item.metadata.baseDir, item.path));
return patterns;
}
private getResourcePatternForScope(item: ResourceItem, scope: SettingsScope): string {
const sourceScope = this.getItemScope(item);
if (scope !== sourceScope) return item.path;
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(sourceScope);
return relative(baseDir, item.path);
}
private createPackageOverrideSource(item: ResourceItem): PackageSource {
const source = item.metadata.source;
if (!isLocalPath(source)) return { source, autoload: false };
const sourcePath = resolvePath(source, this.getTopLevelBaseDir(this.getItemScope(item)), { trim: true });
return { source: relative(this.getTopLevelBaseDir("project"), sourcePath) || ".", autoload: false };
}
private packageSourceStringMatches(
leftSource: string,
leftScope: SettingsScope,
rightSource: string,
rightScope: SettingsScope,
): boolean {
if (leftSource === rightSource) return true;
if (!isLocalPath(leftSource) || !isLocalPath(rightSource)) return false;
const left = resolvePath(leftSource, this.getTopLevelBaseDir(leftScope), { trim: true });
const right = resolvePath(rightSource, this.getTopLevelBaseDir(rightScope), { trim: true });
return left === right;
}
private findMatchingPackageSource(item: ResourceItem, targetScope: SettingsScope): PackageSource | undefined {
const settings =
targetScope === "project"
? this.settingsManager.getProjectSettings()
: this.settingsManager.getGlobalSettings();
return (settings.packages ?? []).find((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
targetScope,
),
);
}
private getPatternEntryTarget(entry: string): string {
return entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
}
private getResourceItemKey(item: ResourceItem): string {
return `${item.resourceType}:${canonicalizePath(item.path)}`;
}
private getItemScope(item: ResourceItem): SettingsScope {
return item.metadata.scope === "project" ? "project" : "user";
}
private getTopLevelBaseDir(scope: "user" | "project"): string {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
@@ -578,7 +864,9 @@ class ResourceList implements Component, Focusable {
}
export class ConfigSelectorComponent extends Container implements Focusable {
private header: ConfigSelectorHeader;
private resourceList: ResourceList;
private writeScope: ConfigWriteScope;
private _focused = false;
get focused(): boolean {
@@ -590,7 +878,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
}
constructor(
resolvedPaths: ResolvedPaths,
resolvedPaths: ScopedResolvedPaths,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
@@ -598,23 +886,43 @@ export class ConfigSelectorComponent extends Container implements Focusable {
onExit: () => void,
requestRender: () => void,
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
projectModeAvailable = true,
) {
super();
const groups = buildGroups(resolvedPaths, agentDir);
this.writeScope = writeScope;
const groupsByScope = {
global: buildGroups(resolvedPaths.global, agentDir),
project: buildGroups(resolvedPaths.project, agentDir),
};
// Add header
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new ConfigSelectorHeader());
this.header = new ConfigSelectorHeader(this.writeScope, projectModeAvailable);
this.addChild(this.header);
this.addChild(new Spacer(1));
// Resource list
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir, terminalHeight);
this.resourceList = new ResourceList(
groupsByScope,
settingsManager,
cwd,
agentDir,
terminalHeight,
this.writeScope,
);
this.resourceList.onCancel = onClose;
this.resourceList.onExit = onExit;
this.resourceList.onToggle = () => requestRender();
if (projectModeAvailable) {
this.resourceList.onSwitchMode = () => {
this.switchWriteScope();
requestRender();
};
}
this.addChild(this.resourceList);
// Bottom border
@@ -622,6 +930,12 @@ export class ConfigSelectorComponent extends Container implements Focusable {
this.addChild(new DynamicBorder());
}
private switchWriteScope(): void {
this.writeScope = this.writeScope === "global" ? "project" : "global";
this.header.setWriteScope(this.writeScope);
this.resourceList.setWriteScope(this.writeScope);
}
getResourceList(): ResourceList {
return this.resourceList;
}