feat: add Pi package management settings

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 15:34:34 +02:00
parent 3f36394c17
commit 8ade238228
28 changed files with 1009 additions and 51 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+33 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { filesApi, machinesApi, piPackagesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -60,6 +60,38 @@ describe("machine-scoped runtime API", () => {
});
});
describe("Pi package API", () => {
it("uses the local Pi package-management routes for list and mutations", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const fetchMock = stubSequenceFetch([
jsonResponse({ packages }),
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
jsonResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages }),
jsonResponse({ action: "update", source: "npm:@acme/tools", packages }),
jsonResponse({ action: "update", packages }),
]);
await expect(piPackagesApi.packages()).resolves.toEqual({ packages });
await piPackagesApi.install("npm:@acme/new-tools");
await piPackagesApi.remove("../project-tools", "project");
await piPackagesApi.update("npm:@acme/tools");
await piPackagesApi.update();
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/pi-packages",
"/api/pi-packages/install",
"/api/pi-packages/remove",
"/api/pi-packages/update",
"/api/pi-packages/update",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "../project-tools", scope: "project" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
});
});
describe("session API compatibility", () => {
it("posts session cleanup preview and execute requests through the selected machine", async () => {
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
+20 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -24,6 +24,8 @@ import {
parseModelSelectionResponse,
parseMoveWorkspaceFileResponse,
parseOAuthFlowState,
parsePiPackageMutationResponse,
parsePiPackagesResponse,
parsePiWebConfigResponse,
parsePiWebPluginsResponse,
parsePiWebRuntimeResponse,
@@ -119,6 +121,22 @@ export const pluginsApi = {
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
};
export const piPackagesApi = {
packages: () => request("/api/pi-packages", parsePiPackagesResponse),
install: (source: string) => {
const body: PiPackageInstallRequest = { source };
return request("/api/pi-packages/install", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
remove: (source: string, scope?: PiPackageScope) => {
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
return request("/api/pi-packages/remove", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
update: (source?: string) => {
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
return request("/api/pi-packages/update", parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
},
};
export const activityApi = {
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
};
@@ -285,6 +303,7 @@ export const api = {
...machinesApi,
...configApi,
...pluginsApi,
...piPackagesApi,
...activityApi,
...projectsApi,
...workspacesApi,
+23 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -31,6 +31,28 @@ describe("API parsers", () => {
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
});
it("parses Pi package list and mutation responses", () => {
const packages = [
{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" },
{ source: "../project-tools", scope: "project", filtered: true },
];
expect(parsePiPackagesResponse({ packages })).toEqual({ packages });
expect(parsePiPackageMutationResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages })).toEqual({
action: "remove",
source: "../project-tools",
scope: "project",
removed: true,
packages,
});
});
it("rejects malformed Pi package responses", () => {
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "global", filtered: false }] })).toThrow("Invalid Pi package scope");
expect(() => parsePiPackageMutationResponse({ action: "sync", packages: [] })).toThrow("Invalid Pi package mutation action");
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
});
it("parses PI WEB plugin status responses", () => {
expect(parsePiWebPluginsResponse({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
+40
View File
@@ -1,4 +1,5 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -628,6 +629,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
}
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
const record = requireRecord(value);
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
}
export function parsePiPackageMutationResponse(value: unknown): PiPackageMutationResponse {
const record = requireRecord(value);
const source = optionalString(record, "source");
const scope = record["scope"] === undefined ? undefined : parsePiPackageScope(record["scope"]);
const removed = parseOptionalBoolean(record["removed"], "removed");
return {
action: parsePiPackageMutationAction(record["action"]),
...optionalField("source", source),
...optionalField("scope", scope),
...optionalField("removed", removed),
packages: arrayOf(parsePiPackageInfo)(record["packages"]),
};
}
function parsePiPackageInfo(value: unknown): PiPackageInfo {
const record = requireRecord(value);
return {
source: requireString(record, "source"),
scope: parsePiPackageScope(record["scope"]),
filtered: requireBoolean(record, "filtered"),
...optionalField("installedPath", optionalString(record, "installedPath")),
};
}
function parsePiPackageScope(value: unknown): PiPackageScope {
if (value !== "user" && value !== "project") throw new Error("Invalid Pi package scope");
return value;
}
function parsePiPackageMutationAction(value: unknown): PiPackageMutationAction {
if (value !== "install" && value !== "remove" && value !== "update") throw new Error("Invalid Pi package mutation action");
return value;
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
const record = requireRecord(value);
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
+60 -4
View File
@@ -1,12 +1,14 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../actions";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import { configApi, piPackagesApi, pluginsApi, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel";
import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
import { piPackageMutationFollowUpMessage, type PiPackageOperationState } from "./settings/piPackageSettings";
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@@ -17,10 +19,13 @@ export class SettingsDialog extends LitElement {
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
@state() private packagesResponse: PiPackagesResponse | undefined;
@state() private loading = true;
@state() private saving = false;
@state() private packageOperation: PiPackageOperationState | undefined;
@state() private error = "";
@state() private savedMessage = "";
@state() private packageMessage = "";
private savedMessageTimer: number | undefined;
override connectedCallback(): void {
@@ -49,7 +54,8 @@ export class SettingsDialog extends LitElement {
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("packages", "Pi packages", "Install and manage")}
${this.renderNavButton("plugins", "PI WEB plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
<main class="settings-content">
@@ -89,6 +95,21 @@ export class SettingsDialog extends LitElement {
></settings-shortcuts-panel>
`;
}
if (this.section === "packages") {
return html`
<settings-packages-panel
.packagesResponse=${this.packagesResponse}
.loading=${this.loading}
.operation=${this.packageOperation}
.error=${this.error}
.operationMessage=${this.packageMessage}
.onReload=${() => this.loadConfig()}
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
></settings-packages-panel>
`;
}
if (this.section === "plugins") {
return html`
<settings-plugins-panel
@@ -134,9 +155,10 @@ export class SettingsDialog extends LitElement {
this.loading = true;
this.error = "";
try {
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
const [config, plugins, packages] = await Promise.all([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages()]);
this.configResponse = config;
this.pluginsResponse = plugins;
this.packagesResponse = packages;
} catch (error) {
this.error = `Failed to load settings: ${errorMessage(error)}`;
} finally {
@@ -163,6 +185,7 @@ export class SettingsDialog extends LitElement {
this.saving = true;
this.error = "";
this.savedMessage = "";
this.packageMessage = "";
try {
const response = await configApi.saveConfig(config);
this.configResponse = response;
@@ -175,11 +198,44 @@ export class SettingsDialog extends LitElement {
}
}
private async installPiPackage(source: string): Promise<void> {
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", () => piPackagesApi.install(source));
}
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", () => piPackagesApi.remove(source, scope));
}
private async updatePiPackage(source?: string): Promise<void> {
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", () => piPackagesApi.update(source));
}
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
if (this.saving) throw new Error("A settings operation is already running.");
this.saving = true;
this.packageOperation = operation;
this.error = "";
this.savedMessage = "";
this.packageMessage = "";
try {
const response = await mutate();
this.packagesResponse = { packages: response.packages };
await this.refreshPlugins();
this.packageMessage = piPackageMutationFollowUpMessage(response.action);
} catch (error) {
this.error = `Failed to ${label}: ${errorMessage(error)}`;
throw error;
} finally {
this.packageOperation = undefined;
this.saving = false;
}
}
private async refreshPlugins(): Promise<void> {
try {
this.pluginsResponse = await pluginsApi.plugins();
} catch (error) {
this.error = `Failed to refresh plugins: ${errorMessage(error)}`;
this.error = `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
}
}
@@ -0,0 +1,180 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api";
import { isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageOperationState } from "./piPackageSettings";
@customElement("settings-packages-panel")
export class SettingsPackagesPanel extends LitElement {
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
@property() error = "";
@property() operationMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onInstallPackage?: (source: string) => void | Promise<void>;
@property({ attribute: false }) onRemovePackage?: (source: string, scope: PiPackageScope) => void | Promise<void>;
@property({ attribute: false }) onUpdatePackage?: (source?: string) => void | Promise<void>;
@state() private installSource = "";
@state() private validationMessage = "";
override render(): TemplateResult {
const packages = this.packagesResponse?.packages ?? [];
return html`
<div class="section-heading">
<div>
<h2>Pi packages</h2>
<p>Install, remove, and update packages managed by Pi. Pi packages can provide extensions, skills, prompt templates, themes, and PI WEB browser plugins.</p>
</div>
<button class="secondary" ?disabled=${this.loading || this.isOperating} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
<div class="trust-warning"><strong>Trusted code warning:</strong> Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.</div>
${this.renderMessages()}
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
<label for="package-source">Pi package source</label>
<div class="install-row">
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
</div>
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
<small>Installs use Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>. PI WEB does not ask you to choose an install location.</small>
</form>
${this.renderPackageList(packages)}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.operationMessage !== "") return html`<div class="message success-message">${this.operationMessage}</div>`;
return null;
}
private renderPackageList(packages: PiPackageInfo[]): TemplateResult {
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
return html`
<section class="package-section" aria-label="Configured Pi packages">
<div class="package-toolbar">
<div>
<h3>Configured Pi packages</h3>
<p>This list comes from Pi's package manager settings visible to this PI WEB process.</p>
</div>
<button class="secondary" title=${updateAllReason ?? "Update all user-scope Pi packages"} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
</button>
</div>
${updateAllReason === undefined ? null : html`<div class="action-note">${updateAllReason}</div>`}
${this.loading && packages.length === 0 ? html`<div class="loading-card">Loading Pi packages…</div>` : packages.length === 0 ? html`<div class="loading-card">No Pi packages configured in Pi settings yet.</div>` : html`
<div class="package-list">
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
</div>
`}
</section>
`;
}
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
const updateReason = piPackageUpdateDisabledReason(packageInfo);
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source);
return html`
<article class=${`package-card${packageInfo.filtered ? " filtered" : ""}`}>
<div class="package-main">
<strong>${packageInfo.source}</strong>
<small>${piPackageScopeLabel(packageInfo)} · ${piPackageFilteredLabel(packageInfo)}</small>
<small>Installed path: <code>${piPackageInstalledPathLabel(packageInfo)}</code></small>
${updateReason === undefined ? null : html`<small class="action-note">${updateReason}</small>`}
</div>
<div class="package-actions">
<button class="secondary" title=${updateReason ?? "Update this Pi package"} ?disabled=${this.isOperating || updateReason !== undefined} @click=${() => { void this.updatePackage(packageInfo.source); }}>${updating ? "Updating…" : "Update"}</button>
<button class="danger" ?disabled=${this.isOperating} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
</div>
</article>
`;
}
private updateInstallSource(event: Event): void {
this.installSource = event.target instanceof HTMLInputElement ? event.target.value : "";
this.validationMessage = "";
}
private async installPackage(event: Event): Promise<void> {
event.preventDefault();
const validationMessage = piPackageSourceValidationMessage(this.installSource);
if (validationMessage !== undefined) {
this.validationMessage = validationMessage;
return;
}
const source = normalizePiPackageSource(this.installSource);
try {
await this.onInstallPackage?.(source);
this.installSource = "";
this.validationMessage = "";
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private async removePackage(packageInfo: PiPackageInfo): Promise<void> {
try {
await this.onRemovePackage?.(packageInfo.source, packageInfo.scope);
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private async updatePackage(source?: string): Promise<void> {
try {
await this.onUpdatePackage?.(source);
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private get isOperating(): boolean {
return this.operation !== undefined;
}
static override styles = css`
:host { display: block; }
.section-heading, .package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div, .package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 15px; line-height: 1.25; }
p, small { color: var(--pi-muted); line-height: 1.45; }
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; }
label { font-weight: 700; }
.secondary { flex: 0 0 auto; }
.danger { border-color: color-mix(in srgb, var(--pi-danger) 55%, var(--pi-border)); color: var(--pi-danger); }
.message, .loading-card, .trust-warning, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .trust-warning, .install-card { margin-bottom: 12px; }
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
.error-message, .field-error { color: var(--pi-danger); }
.error-message { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.install-card { display: grid; gap: 8px; }
.install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.field-error { font-size: 12px; }
.package-section { display: block; }
.package-toolbar { margin-top: 16px; }
.loading-card, .action-note { color: var(--pi-muted); }
.action-note { margin-bottom: 10px; font-size: 12px; }
.package-list { display: grid; gap: 10px; }
.package-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.package-card.filtered { opacity: .82; }
.package-main strong, .package-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-actions { display: flex; align-items: center; gap: 8px; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.section-heading, .package-toolbar { display: grid; gap: 12px; }
.section-heading .secondary, .package-toolbar .secondary { justify-self: start; }
.install-row, .package-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
.package-actions { justify-self: start; flex-wrap: wrap; }
.package-main strong, .package-main small { white-space: normal; }
}
`;
}
@@ -18,14 +18,15 @@ export class SettingsPluginsPanel extends LitElement {
return html`
<div class="section-heading">
<div>
<h2>Plugins</h2>
<p>Enable or disable discovered PI WEB plugins. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
<h2>PI WEB plugins</h2>
<p>Enable or disable discovered PI WEB browser plugins. This is separate from installing Pi packages. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="trust-warning"><strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.</div>
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No external or bundled plugins discovered.</div>` : html`
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered.</div>` : html`
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
@@ -73,11 +74,12 @@ export class SettingsPluginsPanel extends LitElement {
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { margin-bottom: 12px; }
.message, .loading-card, .trust-warning, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .trust-warning { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card, .plugin-note { color: var(--pi-muted); }
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
.plugin-note { margin-bottom: 14px; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.plugin-list { display: grid; gap: 10px; }
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { PiPackageInfo } from "../../api";
import { canUpdateAllPiPackages, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason } from "./piPackageSettings";
const userPackage: PiPackageInfo = { source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" };
const projectPackage: PiPackageInfo = { source: "../project-tools", scope: "project", filtered: true };
describe("Pi package settings helpers", () => {
it("normalizes and validates install sources without adding location choices", () => {
expect(normalizePiPackageSource(" npm:@acme/tools ")).toBe("npm:@acme/tools");
expect(piPackageSourceValidationMessage(" npm:@acme/tools ")).toBeUndefined();
expect(piPackageSourceValidationMessage(" ")).toContain("Pi package source accepted by Pi");
});
it("formats package metadata with Pi package terminology", () => {
expect(piPackageScopeLabel(userPackage)).toBe("User scope");
expect(piPackageScopeLabel(projectPackage)).toBe("Project scope");
expect(piPackageFilteredLabel(userPackage)).toBe("Available in this PI WEB process");
expect(piPackageFilteredLabel(projectPackage)).toBe("Filtered by current Pi package settings");
});
it("allows updates for user-scope packages and explains project-scope limits", () => {
expect(piPackageUpdateDisabledReason(userPackage)).toBeUndefined();
expect(piPackageUpdateDisabledReason(projectPackage)).toContain("user-scope Pi packages");
expect(canUpdateAllPiPackages([userPackage])).toBe(true);
expect(canUpdateAllPiPackages([userPackage, projectPackage])).toBe(false);
expect(updateAllPiPackagesDisabledReason([])).toBe("No Pi packages are configured yet.");
expect(updateAllPiPackagesDisabledReason([userPackage, projectPackage])).toContain("project-scope Pi packages");
});
it("matches pending operations by action and source", () => {
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/tools")).toBe(true);
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/other")).toBe(false);
expect(isPiPackageOperationPending({ kind: "update-all" }, "update-all")).toBe(true);
});
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
const message = piPackageMutationFollowUpMessage("install");
expect(message).toContain("Reload the browser page");
expect(message).toContain("Reload existing Pi sessions");
expect(message).not.toContain("session daemon");
expect(message).not.toContain("sessiond");
});
});
@@ -0,0 +1,58 @@
import type { PiPackageInfo, PiPackageMutationAction } from "../../api";
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
export interface PiPackageOperationState {
kind: PiPackageOperationKind;
source?: string;
}
export function normalizePiPackageSource(source: string): string {
return source.trim();
}
export function piPackageSourceValidationMessage(source: string): string | undefined {
if (normalizePiPackageSource(source) !== "") return undefined;
return "Enter a Pi package source accepted by Pi, such as npm:@scope/package, a git/URL source, or a local path.";
}
export function piPackageScopeLabel(packageInfo: Pick<PiPackageInfo, "scope">): string {
return packageInfo.scope === "project" ? "Project scope" : "User scope";
}
export function piPackageFilteredLabel(packageInfo: Pick<PiPackageInfo, "filtered">): string {
return packageInfo.filtered ? "Filtered by current Pi package settings" : "Available in this PI WEB process";
}
export function piPackageInstalledPathLabel(packageInfo: Pick<PiPackageInfo, "installedPath">): string {
return packageInfo.installedPath ?? "Installed path not reported by Pi";
}
export function canUpdatePiPackage(packageInfo: Pick<PiPackageInfo, "scope">): boolean {
return packageInfo.scope === "user";
}
export function piPackageUpdateDisabledReason(packageInfo: Pick<PiPackageInfo, "scope">): string | undefined {
if (canUpdatePiPackage(packageInfo)) return undefined;
return "Project-scope Pi packages are listed for visibility, but PI WEB only updates user-scope Pi packages safely from this view.";
}
export function canUpdateAllPiPackages(packages: readonly Pick<PiPackageInfo, "scope">[]): boolean {
return packages.length > 0 && packages.every(canUpdatePiPackage);
}
export function updateAllPiPackagesDisabledReason(packages: readonly Pick<PiPackageInfo, "scope">[]): string | undefined {
if (packages.length === 0) return "No Pi packages are configured yet.";
if (canUpdateAllPiPackages(packages)) return undefined;
return "Update all is disabled while project-scope Pi packages are listed; update user-scope packages individually.";
}
export function isPiPackageOperationPending(operation: PiPackageOperationState | undefined, kind: PiPackageOperationKind, source?: string): boolean {
if (operation?.kind !== kind) return false;
return source === undefined || operation.source === source;
}
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction): string {
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
return `Pi package ${verb}. Reload the browser page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use /reload in Pi, so extensions, skills, prompt templates, and themes are rediscovered.`;
}
+2
View File
@@ -37,6 +37,8 @@ describe("settings route helpers", () => {
expect(parseSettingsSection("general")).toBe("general");
expect(parseSettingsSection("sessiond")).toBe("sessiond");
expect(parseSettingsSection("sessions")).toBe("sessiond");
expect(parseSettingsSection("packages")).toBe("packages");
expect(parseSettingsSection("pi-packages")).toBe("packages");
expect(parseSettingsSection("plugins")).toBe("plugins");
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
+2 -1
View File
@@ -1,4 +1,4 @@
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts";
export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
@@ -18,6 +18,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
if (value === "general") return "general";
if (value === "sessiond" || value === "sessions") return "sessiond";
if (value === "packages" || value === "pi-packages") return "packages";
if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
return undefined;