feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled

Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-06-10 16:57:08 +08:00
parent 11c3a3a399
commit cf5edd6394
132 changed files with 9288 additions and 1971 deletions

View File

@@ -1321,7 +1321,8 @@
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"peer": true
},
"node_modules/json-bigint": {
"version": "1.0.0",
@@ -1774,7 +1775,8 @@
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"peer": true
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",

View File

@@ -15,8 +15,15 @@ const MAX_DESCRIPTION_LENGTH = 1024;
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
/** Root-level markdown files that document the skills directory, not skills themselves. */
const IGNORED_ROOT_SKILL_MARKDOWN = new Set(["README.md"]);
type IgnoreMatcher = ReturnType<typeof ignore>;
function isIgnoredRootSkillMarkdown(fileName: string): boolean {
return IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === "readme.md";
}
function toPosixPath(p: string): string {
return p.split(sep).join("/");
}
@@ -259,7 +266,7 @@ function loadSkillsFromDirInternal(
continue;
}
if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) {
if (!isFile || !includeRootFiles || !entry.name.endsWith(".md") || isIgnoredRootSkillMarkdown(entry.name)) {
continue;
}
@@ -278,6 +285,10 @@ function loadSkillFromFile(
filePath: string,
source: string,
): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {
if (isIgnoredRootSkillMarkdown(basename(filePath))) {
return { skill: null, diagnostics: [] };
}
const diagnostics: ResourceDiagnostic[] = [];
try {

View File

@@ -33,8 +33,6 @@ import {
type Component,
Container,
fuzzyFilter,
getCapabilities,
hyperlink,
Loader,
type LoaderIndicatorOptions,
Markdown,
@@ -51,7 +49,6 @@ import { spawn, spawnSync } from "child_process";
import {
APP_NAME,
APP_TITLE,
getAgentDir,
getAuthPath,
getDebugLogPath,
getDocsPath,
@@ -75,7 +72,6 @@ import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/htt
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
@@ -92,7 +88,6 @@ import { getCwdRelativePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
import { BashExecutionComponent } from "./components/bash-execution.ts";
@@ -716,20 +711,6 @@ export class InteractiveMode {
async run(): Promise<void> {
await this.init();
// Start version check asynchronously
checkForNewPiVersion(this.version).then((newRelease) => {
if (newRelease) {
this.showNewVersionNotification(newRelease);
}
});
// Start package update check asynchronously
this.checkForPackageUpdates().then((updates) => {
if (updates.length > 0) {
this.showPackageUpdateNotification(updates);
}
});
// Check tmux keyboard setup asynchronously
this.checkTmuxKeyboardSetup().then((warning) => {
if (warning) {
@@ -788,24 +769,6 @@ export class InteractiveMode {
}
}
private async checkForPackageUpdates(): Promise<string[]> {
if (process.env.PI_OFFLINE) {
return [];
}
try {
const packageManager = new DefaultPackageManager({
cwd: this.sessionManager.getCwd(),
agentDir: getAgentDir(),
settingsManager: this.settingsManager,
});
const updates = await packageManager.checkForAvailableUpdates();
return updates.map((update) => update.displayName);
} catch {
return [];
}
}
private async checkTmuxKeyboardSetup(): Promise<string | undefined> {
if (!process.env.TMUX) return undefined;
@@ -1172,6 +1135,24 @@ export class InteractiveMode {
);
}
private splitExtensionsByOrigin(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): {
local: Array<{ path: string; sourceInfo?: SourceInfo }>;
packages: Array<{ path: string; sourceInfo?: SourceInfo }>;
} {
const local: Array<{ path: string; sourceInfo?: SourceInfo }> = [];
const packages: Array<{ path: string; sourceInfo?: SourceInfo }> = [];
for (const extension of extensions) {
if (this.isPackageSource(extension.sourceInfo)) {
packages.push(extension);
} else {
local.push(extension);
}
}
return { local, packages };
}
private formatScopeGroups(
groups: Array<{
scope: "user" | "project" | "path";
@@ -1294,23 +1275,58 @@ export class InteractiveMode {
return;
}
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
const formatCompactList = (items: string[], options?: { sort?: boolean }): string => {
const sectionHeader = (name: string, count?: number, color: ThemeColor = "mdHeading") => {
const title = theme.fg(color, `[${name}]`);
if (count === undefined) {
return title;
}
return `${title} ${theme.fg("muted", String(count))}`;
};
const formatCompactGrid = (
items: string[],
options?: { sort?: boolean; columns?: number; indent?: number },
): string => {
const columns = Math.max(1, options?.columns ?? 4);
const indent = " ".repeat(options?.indent ?? 2);
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(", ")}`);
if (labels.length === 0) {
return "";
}
const rows: string[][] = [];
for (let index = 0; index < labels.length; index += columns) {
rows.push(labels.slice(index, index + columns));
}
const columnWidths = Array.from({ length: columns }, (_, columnIndex) =>
Math.max(0, ...rows.map((row) => row[columnIndex]?.length ?? 0)),
);
const body = rows
.map((row) => {
const cells = row.map((label, columnIndex) => {
const width = columnWidths[columnIndex];
return width > 0 ? label.padEnd(width + 2) : "";
});
return `${indent}${cells.join("").trimEnd()}`;
})
.join("\n");
return theme.fg("dim", body);
};
const addLoadedSection = (
name: string,
collapsedBody: string,
expandedBody = collapsedBody,
color: ThemeColor = "mdHeading",
count?: number,
): void => {
const section = new ExpandableText(
() => `${sectionHeader(name, color)}\n${collapsedBody}`,
() => `${sectionHeader(name, color)}\n${expandedBody}`,
() => `${sectionHeader(name, count, color)}\n${collapsedBody}`,
() => `${sectionHeader(name, count, color)}\n${expandedBody}`,
this.getStartupExpansionState(),
0,
0,
@@ -1357,11 +1373,11 @@ export class InteractiveMode {
const contextList = contextFiles
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
.join("\n");
const contextCompactList = formatCompactList(
const contextCompactList = formatCompactGrid(
contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),
{ sort: false },
);
addLoadedSection("Context", contextCompactList, contextList);
addLoadedSection("系统设定", contextCompactList, contextList, "mdHeading", contextFiles.length);
}
const skills = skillsResult.skills;
@@ -1373,8 +1389,8 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
const skillCompactList = formatCompactList(skills.map((skill) => skill.name));
addLoadedSection("Skills", skillCompactList, skillList);
const skillCompactList = formatCompactGrid(skills.map((skill) => skill.name));
addLoadedSection("技能", skillCompactList, skillList, "mdHeading", skills.length);
}
const templates = this.session.promptTemplates;
@@ -1393,19 +1409,32 @@ export class InteractiveMode {
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
},
});
const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));
addLoadedSection("Prompts", promptCompactList, templateList);
const promptCompactList = formatCompactGrid(templates.map((template) => `/${template.name}`));
addLoadedSection("指令", promptCompactList, templateList, "mdHeading", templates.length);
}
if (extensions.length > 0) {
const groups = this.buildScopeGroups(extensions);
const extList = this.formatScopeGroups(groups, {
formatPath: (item) => this.formatExtensionDisplayPath(item.path),
formatPackagePath: (item) =>
const extensionFormatOptions = {
formatPath: (item: { path: string; sourceInfo?: SourceInfo }) =>
this.formatExtensionDisplayPath(item.path),
formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }) =>
this.formatExtensionDisplayPath(this.getShortPath(item.path, item.sourceInfo)),
});
const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions));
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
};
const { local, packages } = this.splitExtensionsByOrigin(extensions);
if (local.length > 0) {
const localGroups = this.buildScopeGroups(local);
const localList = this.formatScopeGroups(localGroups, extensionFormatOptions);
const localCompactList = formatCompactGrid(this.getCompactExtensionLabels(local));
addLoadedSection("本地扩展", localCompactList, localList, "mdHeading", local.length);
}
if (packages.length > 0) {
const packageGroups = this.buildScopeGroups(packages);
const packageList = this.formatScopeGroups(packageGroups, extensionFormatOptions);
const packageCompactList = formatCompactGrid(this.getCompactExtensionLabels(packages));
addLoadedSection("npm 扩展", packageCompactList, packageList, "mdHeading", packages.length);
}
}
// Show loaded themes (excluding built-in)
@@ -1422,13 +1451,13 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
const themeCompactList = formatCompactList(
const themeCompactList = formatCompactGrid(
customThemes.map(
(loadedTheme) =>
loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath!, loadedTheme.sourceInfo),
),
);
addLoadedSection("Themes", themeCompactList, themeList);
addLoadedSection("Themes", themeCompactList, themeList, "mdHeading", customThemes.length);
}
}
@@ -3591,53 +3620,6 @@ export class InteractiveMode {
this.ui.requestRender();
}
showNewVersionNotification(release: LatestPiRelease): void {
const action = theme.fg("accent", `${APP_NAME} update`);
const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action;
const changelogUrl = "https://pi.dev/changelog";
const changelogLink = getCapabilities().hyperlinks
? hyperlink(theme.fg("accent", "open changelog"), changelogUrl)
: theme.fg("accent", changelogUrl);
const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink;
const note = release.note?.trim();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0),
);
if (note) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), {
color: (text) => theme.fg("muted", text),
}),
);
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new Text(changelogLine, 1, 0));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
showPackageUpdateNotification(packages: string[]): void {
const action = theme.fg("accent", `${APP_NAME} update`);
const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action;
const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
`${theme.bold(theme.fg("warning", "Package Updates Available"))}\n${updateInstruction}\n${theme.fg("muted", "Packages:")}\n${packageLines}`,
1,
0,
),
);
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
/**
* Get all queued messages (read-only).
* Combines session queue and compaction queue.

View File

@@ -0,0 +1,3 @@
# Skills
Documentation for this skills directory. Not a skill file.

View File

@@ -0,0 +1,6 @@
---
name: nested-skill
description: Nested skill beside a README.
---
# Nested Skill

View File

@@ -287,6 +287,8 @@ describe("InteractiveMode.showLoadedResources", () => {
) => (InteractiveMode as any).prototype.getCompactNonPackageExtensionLabel.call(fakeThis, p, index, allPaths),
getCompactExtensionLabels: (extensions: ExtensionFixture[]) =>
(InteractiveMode as any).prototype.getCompactExtensionLabels.call(fakeThis, extensions),
splitExtensionsByOrigin: (extensions: ExtensionFixture[]) =>
(InteractiveMode as any).prototype.splitExtensionsByOrigin.call(fakeThis, extensions),
formatDiagnostics: () => "diagnostics",
getBuiltInCommandConflictDiagnostics: () => [],
};
@@ -415,7 +417,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("[技能]");
expect(output).toContain("commit");
expect(output).not.toContain("resource-list");
});
@@ -432,7 +434,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("[技能]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
@@ -450,7 +452,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("[技能]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
@@ -466,8 +468,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Extensions]");
expect(output).toContain("answer.ts, btw.ts");
expect(output).toContain("[本地扩展]");
expect(output).toContain("answer.ts");
expect(output).toContain("btw.ts");
expect(output).not.toContain("extensions/answer.ts");
});
@@ -483,8 +486,12 @@ describe("InteractiveMode.showLoadedResources", () => {
});
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, pi-markdown-preview, user-index"`);
"[本地扩展] 4
answer.ts cli-extension.ts local-index user-index
[npm 扩展] 4
@scope/pi-scoped HazAT/pi-interactive-subagents HazAT/pi-interactive-subagents:subagents pi-markdown-preview"
`);
});
test("adds more parent folders until local extension labels are unique", () => {
@@ -529,8 +536,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
alpha/one, beta/one, gamma/one"`);
"[本地扩展] 3
alpha/one beta/one gamma/one"
`);
});
test("strips index.ts from local extension label, showing parent dir", () => {
@@ -557,8 +565,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
plan-mode"`);
"[本地扩展] 1
plan-mode"
`);
});
test("strips index.js from local extension label, showing parent dir", () => {
@@ -585,8 +594,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
plan-mode"`);
"[本地扩展] 1
plan-mode"
`);
});
test("mixed single-file and subdirectory index.ts extensions strip index.ts", () => {
@@ -622,8 +632,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
plan-mode, webfetch.ts"`);
"[本地扩展] 2
plan-mode webfetch.ts"
`);
});
test("multiple index.ts with unique parent dirs need no disambiguation", () => {
@@ -659,8 +670,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
bar, foo"`);
"[本地扩展] 2
bar foo"
`);
});
test("multiple index.ts with same parent dir name disambiguated with grandparent", () => {
@@ -696,8 +708,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
alpha/tools, beta/tools"`);
"[本地扩展] 2
alpha/tools beta/tools"
`);
});
test("non-index file in subdirectory stays as filename", () => {
@@ -724,8 +737,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
main.ts"`);
"[本地扩展] 1
main.ts"
`);
});
test("package extensions still strip index.ts correctly (regression guard)", () => {
@@ -752,8 +766,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
pi-markdown-preview"`);
"[npm 扩展] 1
pi-markdown-preview"
`);
});
test("captures mixed extension layouts in expanded output", () => {
const fakeThis = createShowLoadedResourcesThis({
@@ -768,21 +783,25 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
project
/tmp/project/.pi/extensions/answer.ts
/tmp/project/.pi/extensions/local-index
git:github.com/HazAT/pi-interactive-subagents
extensions
extensions/subagents
npm:@scope/pi-scoped
extensions
npm:pi-markdown-preview
extensions
user
/tmp/agent/extensions/user-index
path
/tmp/temp/cli-extension.ts"`);
"[本地扩展] 4
project
/tmp/project/.pi/extensions/answer.ts
/tmp/project/.pi/extensions/local-index
user
/tmp/agent/extensions/user-index
path
/tmp/temp/cli-extension.ts
[npm 扩展] 4
project
git:github.com/HazAT/pi-interactive-subagents
extensions
extensions/subagents
npm:@scope/pi-scoped
extensions
npm:pi-markdown-preview
extensions"
`);
});
test("shows context paths relative to cwd while preserving full external paths", () => {
@@ -799,8 +818,10 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
expect(output).toContain("[系统设定]");
expect(output).toContain("~/.pi/agent/AGENTS.md");
expect(output).toContain("AGENTS.md");
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
});
@@ -819,7 +840,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("[系统设定]");
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");
@@ -854,6 +875,6 @@ describe("InteractiveMode.showLoadedResources", () => {
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skill conflicts]");
expect(output).not.toContain("[Skills]");
expect(output).not.toContain("[技能]");
});
});

View File

@@ -105,6 +105,17 @@ describe("skills", () => {
expect(diagnostics).toHaveLength(0);
});
it("should ignore README.md at the skills directory root", () => {
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "skills-dir-readme"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("nested-skill");
expect(diagnostics).toHaveLength(0);
});
it("should prefer a directory's root SKILL.md over nested SKILL.md files", () => {
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "root-skill-preferred"),