Merge remote-tracking branch 'origin/main' into review/pr-5-machine-federation-fixes

# Conflicts:
#	src/client/src/api.ts
#	src/client/src/api/clients.ts
#	src/client/src/api/parsers.ts
#	src/client/src/components/PiWebApp.ts
#	src/server/app.ts
#	src/shared/apiTypes.ts
This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 10:31:23 +02:00
76 changed files with 2423 additions and 767 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
export { activityApi, api, filesApi, gitApi, machinesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+14 -1
View File
@@ -1,4 +1,4 @@
import type { FileSuggestion, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -20,6 +20,8 @@ import {
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
parsePiWebConfigResponse,
parsePiWebPluginsResponse,
parsePiWebStatusResponse,
parseProject,
parseRestored,
@@ -48,6 +50,15 @@ export const machinesApi = {
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
};
export const configApi = {
config: () => request("/api/config", parsePiWebConfigResponse),
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
};
export const pluginsApi = {
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
};
export const activityApi = {
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
};
@@ -170,6 +181,8 @@ export const gitApi = {
export const api = {
...piWebApi,
...machinesApi,
...configApi,
...pluginsApi,
...activityApi,
...projectsApi,
...workspacesApi,
+25 -1
View File
@@ -1,7 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
envOverrides: { host: true, port: false, allowedHosts: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
envOverrides: { host: true, port: false, allowedHosts: false },
});
});
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", enabled: false }],
})).toEqual({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
});
});
it("accepts legacy array message pages and paged message responses", () => {
expect(parseMessagePage(["a", "b"])).toEqual({ messages: ["a", "b"], start: 0, total: 2 });
expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 });
+78 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -408,6 +408,83 @@ export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivit
return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") };
}
export function parsePiWebConfigResponse(value: unknown): PiWebConfigResponse {
const record = requireRecord(value);
return {
path: requireString(record, "path"),
exists: requireBoolean(record, "exists"),
config: parsePiWebConfigValues(record["config"]),
effectiveConfig: parsePiWebConfigValues(record["effectiveConfig"]),
envOverrides: parsePiWebConfigEnvOverrides(record["envOverrides"]),
};
}
function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
const record = requireRecord(value);
return {
...optionalField("host", optionalString(record, "host")),
...optionalField("port", optionalNumber(record, "port")),
...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])),
...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])),
...optionalField("plugins", optionalPlugins(record["plugins"])),
};
}
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
if (value === undefined) return undefined;
if (value === true) return true;
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
throw new Error("Invalid PI WEB allowedHosts field");
}
function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field");
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) throw new Error("Invalid PI WEB shortcut field");
return [actionId, shortcut];
}));
}
function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB plugins field");
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
if (!isRecord(config) || Array.isArray(config)) throw new Error("Invalid PI WEB plugin config field");
const enabled = config["enabled"];
if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("Invalid PI WEB plugin enabled field");
const settings = config["settings"];
if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("Invalid PI WEB plugin settings field");
return [pluginId, config];
}));
}
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
const record = requireRecord(value);
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") };
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
const record = requireRecord(value);
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
}
function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo {
const record = requireRecord(value);
return {
id: requireString(record, "id"),
module: requireString(record, "module"),
source: requireString(record, "source"),
scope: parsePiWebPluginScope(record["scope"]),
enabled: requireBoolean(record, "enabled"),
};
}
function parsePiWebPluginScope(value: unknown): PiWebPluginScope {
if (value !== "bundled" && value !== "local" && value !== "user" && value !== "project") throw new Error("Invalid PI WEB plugin scope");
return value;
}
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
const record = requireRecord(value);
return {
+75 -13
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { piWebApi, terminalsApi, type Machine, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { configApi, piWebApi, terminalsApi, type Machine, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -27,6 +27,8 @@ import { AppShellController } from "../appShell/appShellController";
import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState";
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
import { readRoute, writeRoute, type AppRoute } from "../route";
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
import { applyShortcutPreferences } from "../shortcutPreferences";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import "./MachineList";
@@ -42,11 +44,12 @@ import "./CommandPicker";
import "./ActionPalette";
import "./AuthDialog";
import "./ProjectDialog";
import "./SettingsDialog";
import "./WorkspacePanel";
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import "./appShell/AppContextBar";
import "./appShell/AppMobileMainTabs";
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
import "./appShell/AppNavigationPanel";
import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl";
@@ -131,7 +134,12 @@ export class PiWebApp extends LitElement {
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isRefreshingApp = false;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
@state() private settingsSection: SettingsSection | undefined = readSettingsSection();
@state() private shortcutConfig: PiWebShortcutConfig = {};
private readonly onPopState = () => void this.withChatScrollTransition(async () => {
this.restoreSettingsRoute();
await this.restoreRoute(false);
});
private readonly onPageShow = () => {
this.appShell.repairViewportPosition();
};
@@ -178,6 +186,7 @@ export class PiWebApp extends LitElement {
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.loadClientConfig();
void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute();
}
@@ -211,6 +220,7 @@ export class PiWebApp extends LitElement {
}
private async loadProjectsAndRestoreRoute() {
this.restoreSettingsRoute();
const route = readRoute();
await this.machines.loadMachines(route.machineId);
const machineFallbackMessage = this.state.error;
@@ -238,6 +248,18 @@ export class PiWebApp extends LitElement {
}
}
private async loadClientConfig(): Promise<void> {
try {
this.applyClientConfig((await configApi.config()).config);
} catch (error) {
console.warn("Failed to load PI WEB config", error);
}
}
private applyClientConfig(config: PiWebConfigValues): void {
this.shortcutConfig = config.shortcuts ?? {};
}
private async refreshAppData(): Promise<void> {
if (this.isRefreshingApp) return;
this.isRefreshingApp = true;
@@ -246,6 +268,7 @@ export class PiWebApp extends LitElement {
this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(),
this.refreshWorkspaceActivity(),
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
]);
@@ -447,6 +470,25 @@ export class PiWebApp extends LitElement {
this.git.updatePolling();
}
private openSettings(section: SettingsSection = "general"): void {
this.settingsSection = section;
writeSettingsSection(section);
}
private closeSettings(): void {
this.settingsSection = undefined;
writeSettingsSection(undefined);
}
private navigateSettings(section: SettingsSection): void {
this.settingsSection = section;
writeSettingsSection(section);
}
private restoreSettingsRoute(): void {
this.settingsSection = readSettingsSection();
}
private handleWorkspaceChange(previous: AppState, next: AppState) {
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return;
this.terminalAutoStartWorkspaceId = undefined;
@@ -695,12 +737,19 @@ export class PiWebApp extends LitElement {
return "Select a project and workspace to start a session.";
}
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
private mobilePanelBadge(panel: QualifiedWorkspacePanelContribution): unknown {
const workspace = this.state.selectedWorkspace;
if (workspace === undefined) return panel.title;
const badge = panel.badge?.(this.createWorkspacePanelContext(workspace));
if (badge === undefined || badge === "") return panel.title;
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
if (workspace === undefined) return undefined;
return panel.badge?.(this.createWorkspacePanelContext(workspace));
}
private mobilePanelIcon(panel: QualifiedWorkspacePanelContribution): AppMobileMainTabIcon | undefined {
switch (panel.id) {
case "core:workspace.files": return "files";
case "core:workspace.git": return "git";
case "core:workspace.terminal": return "terminal";
default: return undefined;
}
}
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
@@ -733,7 +782,7 @@ export class PiWebApp extends LitElement {
}
private getActions(): AppAction[] {
return this.plugins.getActions(this.createPluginRuntimeContext());
return applyShortcutPreferences(this.plugins.getActions(this.createPluginRuntimeContext()), this.shortcutConfig);
}
private async loadExternalPlugins(): Promise<void> {
@@ -756,7 +805,10 @@ export class PiWebApp extends LitElement {
private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
piWebInternal: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
},
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); },
addProject: () => { this.setState({ projectDialogOpen: true }); },
@@ -1070,6 +1122,7 @@ export class PiWebApp extends LitElement {
.session=${this.state.selectedSession}
.refreshControl=${this.appShell.shouldShowAppRefreshInContextBar() ? this.renderAppRefresh() : undefined}
.onOpenSection=${(section: NavigationSection) => { this.openNavigationSection(section); }}
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
></app-context-bar>
`;
}
@@ -1086,9 +1139,17 @@ export class PiWebApp extends LitElement {
private mobileMainTabs(): AppMobileMainTab[] {
return [
{ id: "navigation", label: "Sessions", className: "navigation-tab" },
{ id: "chat", label: "Chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })),
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
{ id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = panel.icon ?? this.mobilePanelIcon(panel);
return {
id: panel.id,
label: panel.title,
...(icon === undefined ? {} : { icon }),
badge: this.mobilePanelBadge(panel),
};
}),
];
}
@@ -1122,6 +1183,7 @@ export class PiWebApp extends LitElement {
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div>
`;
}
+208
View File
@@ -0,0 +1,208 @@
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 type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@property({ attribute: false }) section: SettingsSection = "general";
@property({ attribute: false }) actions: AppAction[] = [];
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
@state() private loading = true;
@state() private saving = false;
@state() private error = "";
@state() private savedMessage = "";
private savedMessageTimer: number | undefined;
override connectedCallback(): void {
super.connectedCallback();
void this.loadConfig();
}
override disconnectedCallback(): void {
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
this.savedMessageTimer = undefined;
super.disconnectedCallback();
}
override render(): TemplateResult {
return html`
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
<section class="settings-shell" role="dialog" aria-modal="true" aria-label="PI WEB settings" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header class="settings-header">
<div>
<span class="eyebrow">Settings</span>
<h1>PI WEB</h1>
</div>
<button class="close-button" title="Close settings" aria-label="Close settings" @click=${() => this.onClose?.()}>×</button>
</header>
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
<main class="settings-content">
${this.renderActiveSection()}
</main>
</div>
</section>
</div>
`;
}
private renderActiveSection(): TemplateResult {
if (this.section === "shortcuts") {
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
}
if (this.section === "plugins") {
return html`
<settings-plugins-panel
.configResponse=${this.configResponse}
.pluginsResponse=${this.pluginsResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
></settings-plugins-panel>
`;
}
return html`
<settings-general-panel
.configResponse=${this.configResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
></settings-general-panel>
`;
}
private renderNavButton(section: SettingsSection, label: string, detail: string): TemplateResult {
const selected = this.section === section;
return html`
<button class=${selected ? "selected" : ""} aria-current=${selected ? "page" : "false"} @click=${() => { this.navigate(section); }}>
<strong>${label}</strong>
<small>${detail}</small>
</button>
`;
}
private navigate(section: SettingsSection): void {
this.onNavigate?.(section);
}
private async loadConfig(): Promise<void> {
this.loading = true;
this.error = "";
try {
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
this.configResponse = config;
this.pluginsResponse = plugins;
} catch (error) {
this.error = `Failed to load settings: ${errorMessage(error)}`;
} finally {
this.loading = false;
}
}
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
const baseConfig = this.configResponse?.config ?? {};
const currentPlugins = baseConfig.plugins ?? {};
const currentPluginConfig = currentPlugins[pluginId] ?? {};
await this.saveConfig({
...baseConfig,
plugins: {
...currentPlugins,
[pluginId]: { ...currentPluginConfig, enabled },
},
});
await this.refreshPlugins();
}
private async saveConfig(config: PiWebConfigValues): Promise<void> {
if (this.saving) return;
this.saving = true;
this.error = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(config);
this.configResponse = response;
this.onConfigSaved?.(response.config);
this.showSavedMessage();
} catch (error) {
this.error = `Failed to save config: ${errorMessage(error)}`;
} finally {
this.saving = false;
}
}
private async refreshPlugins(): Promise<void> {
try {
this.pluginsResponse = await pluginsApi.plugins();
} catch (error) {
this.error = `Failed to refresh plugins: ${errorMessage(error)}`;
}
}
private showSavedMessage(): void {
this.savedMessage = "Config saved.";
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
this.savedMessageTimer = window.setTimeout(() => {
if (this.savedMessage === "Config saved.") this.savedMessage = "";
this.savedMessageTimer = undefined;
}, 3000);
}
private handleKeyDown(event: KeyboardEvent): void {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
this.onClose?.();
}
static override styles = css`
:host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.backdrop { box-sizing: border-box; width: 100%; height: 100dvh; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); overflow: hidden; }
.settings-shell { width: min(980px, 100%); max-height: min(760px, 100%); min-height: min(620px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
.settings-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border); }
.eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
h1 { margin: 0; font-size: 20px; line-height: 1.2; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; }
.close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; }
.close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); }
.settings-body { min-height: 0; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.settings-nav { min-height: 0; padding: 10px; border-right: 1px solid var(--pi-border); background: var(--pi-surface); overflow: auto; }
.settings-nav button { display: grid; gap: 2px; width: 100%; margin: 0 0 6px; text-align: left; border-color: transparent; background: transparent; }
.settings-nav button:hover, .settings-nav button:focus { background: var(--pi-surface-hover); }
.settings-nav button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.settings-nav small { color: var(--pi-muted); }
.settings-content { min-width: 0; min-height: 0; overflow: auto; padding: 18px; }
@media (max-width: 760px) {
.backdrop { padding: 0; place-items: stretch; }
.settings-shell { width: 100%; height: 100dvh; max-height: none; min-height: 0; border: 0; border-radius: 0; }
.settings-header { padding: max(12px, env(safe-area-inset-top)) 12px 12px; }
.settings-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.settings-nav { display: flex; gap: 8px; padding: 8px; border-right: 0; border-bottom: 1px solid var(--pi-border); overflow-x: auto; overflow-y: hidden; }
.settings-nav button { flex: 0 0 auto; width: auto; min-width: 128px; margin: 0; }
.settings-content { padding: 14px 12px calc(18px + env(safe-area-inset-bottom)); }
}
`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -11,6 +11,7 @@ export class AppContextBar extends LitElement {
@property({ attribute: false }) session?: SessionInfo;
@property({ attribute: false }) refreshControl: unknown;
@property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void;
@property({ attribute: false }) onShowActions?: () => void;
@query(".context-items") private contextItems?: HTMLElement | null;
@state() private canScrollLeft = false;
@state() private canScrollRight = false;
@@ -68,19 +69,35 @@ export class AppContextBar extends LitElement {
</button>
</li>
</ol>
${this.refreshControl === undefined ? null : html`<div class="context-actions">${this.refreshControl}</div>`}
${this.hasContextActions() ? html`<div class="context-actions">${this.renderActionsButton()}${this.refreshControl}</div>` : null}
</nav>
`;
}
private renderActionsButton() {
if (this.onShowActions === undefined) return null;
return html`
<button type="button" class="context-action-button" title="Show Actions" aria-label="Show Actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.onShowActions?.(); }}>
<svg class="context-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M13 2 4 14h7l-1 8 10-13h-7V2Z"></path>
</svg>
</button>
`;
}
private contextBarClass(): string {
const classes = ["context-bar"];
if (this.refreshControl !== undefined) classes.push("has-context-actions");
if (this.hasContextActions()) classes.push("has-context-actions");
if (this.refreshControl !== undefined && this.onShowActions !== undefined) classes.push("has-context-actions-double");
if (this.canScrollLeft) classes.push("can-scroll-left");
if (this.canScrollRight) classes.push("can-scroll-right");
return classes.join(" ");
}
private hasContextActions(): boolean {
return this.refreshControl !== undefined || this.onShowActions !== undefined;
}
private observeContextItems(): void {
const contextItems = this.contextItemsElement();
if (this.observedContextItems === contextItems) return;
@@ -113,7 +130,8 @@ export class AppContextBar extends LitElement {
};
static override styles = css`
:host { flex: 0 0 auto; min-width: 0; }
/* Keep the refresh menu in this shadow tree above the following mobile tab strip. */
:host { position: relative; z-index: 20; flex: 0 0 auto; min-width: 0; }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
@@ -121,11 +139,15 @@ export class AppContextBar extends LitElement {
.context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; }
.context-bar-label { display: none; }
.context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; }
.context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; }
.context-bar.has-context-actions .context-items { padding-right: 58px; scroll-padding-inline: 8px 58px; }
.context-bar.has-context-actions-double .context-items { padding-right: 102px; scroll-padding-inline: 8px 102px; }
.context-item { flex: 0 0 auto; min-width: 0; display: flex; }
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
.context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
app-refresh-control { pointer-events: auto; }
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 0 8px; background: var(--pi-bg); pointer-events: none; }
.context-actions::before { content: ""; position: absolute; top: 0; bottom: 0; left: -24px; z-index: 0; width: 24px; background: linear-gradient(90deg, transparent, var(--pi-bg)); pointer-events: none; }
app-refresh-control, .context-action-button { position: relative; z-index: 1; pointer-events: auto; }
.context-action-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; }
.context-action-button:hover, .context-action-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.context-action-icon { width: 18px; height: 18px; fill: currentColor; pointer-events: none; }
.context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; }
.context-chip:hover { background: var(--pi-surface-hover); }
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
@@ -1,10 +1,15 @@
import { LitElement, css, html } from "lit";
import { LitElement, css, html, svg, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { AppState } from "../../appState";
export type AppMobileMainTabBuiltinIcon = "navigation" | "chat" | "files" | "git" | "terminal";
export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult;
export interface AppMobileMainTab {
id: AppState["mainView"];
label: unknown;
label: string;
icon?: AppMobileMainTabIcon;
badge?: unknown;
className?: string | undefined;
}
@@ -37,12 +42,20 @@ export class AppMobileMainTabs extends LitElement {
}
override render() {
const fallbackLabels = this.fallbackLabels();
return html`
<div class=${this.frameClass()}>
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
${this.tabs.map((tab) => html`
<button class=${this.tabClass(tab)} @click=${() => { this.onSelect?.(tab.id); }}>${tab.label}</button>
`)}
${this.tabs.map((tab) => {
const selected = this.selectedView === tab.id;
return html`
<button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}>
${this.renderTabMark(tab, fallbackLabels)}
<span class="tab-label">${tab.label}</span>
${this.isEmptyBadge(tab.badge) ? null : html`<span class="tab-badge">${tab.badge}</span>`}
</button>
`;
})}
</div>
</div>
`;
@@ -59,6 +72,99 @@ export class AppMobileMainTabs extends LitElement {
].join(" ");
}
private tabAriaLabel(tab: AppMobileMainTab): string {
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
const badge = String(tab.badge).trim();
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
}
private isEmptyBadge(badge: unknown): boolean {
return badge === undefined || badge === "";
}
private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) {
return tab.icon === undefined
? html`<span class="tab-fallback" aria-hidden="true">${fallbackLabels.get(tab.id) ?? this.initialsLabel(tab.label)}</span>`
: this.renderIcon(tab.icon);
}
private fallbackLabels(): Map<AppState["mainView"], string> {
const fallbackTabs = this.tabs.filter((tab) => tab.icon === undefined);
const counts = new Map<string, number>();
for (const tab of fallbackTabs) {
const initials = this.initialsLabel(tab.label);
counts.set(initials, (counts.get(initials) ?? 0) + 1);
}
const labels = new Map<AppState["mainView"], string>();
for (const tab of fallbackTabs) {
const initials = this.initialsLabel(tab.label);
labels.set(tab.id, (counts.get(initials) ?? 0) > 1 ? this.fullFallbackLabel(tab.label) : initials);
}
return labels;
}
private initialsLabel(label: string): string {
const words = label.match(/[\p{L}\p{N}]+/gu) ?? [];
const initials = words.map((word) => Array.from(word)[0] ?? "").join("").toLocaleUpperCase();
return initials === "" ? "?" : initials;
}
private fullFallbackLabel(label: string): string {
const trimmed = label.trim();
return trimmed === "" ? "?" : trimmed;
}
private renderIcon(icon: AppMobileMainTabIcon) {
if (typeof icon !== "string") return html`<span class="tab-custom-icon" aria-hidden="true">${icon}</span>`;
switch (icon) {
case "navigation":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="6" cy="7" r="1.5"></circle>
<path d="M10 7h8"></path>
<circle cx="6" cy="12" r="1.5"></circle>
<path d="M10 12h8"></path>
<circle cx="6" cy="17" r="1.5"></circle>
<path d="M10 17h8"></path>
</svg>
`;
case "chat":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 5h10a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-6l-5 4v-4H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z"></path>
<path d="M8 9h8"></path>
<path d="M8 13h5"></path>
</svg>
`;
case "files":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
</svg>
`;
case "git":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="6" cy="6" r="2"></circle>
<circle cx="18" cy="6" r="2"></circle>
<circle cx="12" cy="18" r="2"></circle>
<path d="M8 6h6"></path>
<path d="M6 8v2a6 6 0 0 0 6 6"></path>
<path d="M18 8v2a6 6 0 0 1-6 6"></path>
</svg>
`;
case "terminal":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<path d="m7 10 3 3-3 3"></path>
<path d="M12 16h5"></path>
</svg>
`;
}
}
private observeMobileTabs(): void {
const mobileTabs = this.mobileTabsElement();
if (this.observedMobileTabs === mobileTabs) return;
@@ -98,13 +204,23 @@ export class AppMobileMainTabs extends LitElement {
.mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; }
.mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.mobile-tabs button { flex: 0 0 auto; white-space: nowrap; }
.navigation-tab { display: none; }
.mobile-tabs button { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.mobile-tabs .navigation-tab { display: none; }
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
.tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.tab-custom-icon { flex: 0 0 auto; width: 18px; height: 18px; display: inline-grid; place-items: center; color: currentColor; pointer-events: none; }
.tab-custom-icon svg { width: 18px; height: 18px; pointer-events: none; }
.tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; }
.tab-label { min-width: 0; }
.tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
@media (max-width: 760px) {
.navigation-tab { display: block; }
.mobile-tabs { gap: 4px; padding: 6px 8px; }
.mobile-tabs button { min-width: 40px; height: 36px; justify-content: center; gap: 4px; padding: 0 8px; }
.mobile-tabs .navigation-tab { display: inline-flex; }
.tab-fallback { display: inline-block; }
.tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
.tab-badge { min-width: 13px; padding: 0 4px; font-size: 10px; line-height: 13px; }
}
`;
}
@@ -120,6 +120,7 @@ export class AppNavigationPanel extends LitElement {
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
:host([collapsible]) { flex: 1 1 auto; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
:host([collapsible]) header { display: none; }
.header-actions { display: flex; align-items: center; gap: 8px; }
machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
@@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import { actionMenuPanelStyle } from "../actionMenu";
const REFRESH_LONG_PRESS_MS = 550;
const REFRESH_MENU_PORTAL_STYLE_ID = "pi-web-app-refresh-menu-portal-style";
@customElement("app-refresh-control")
export class AppRefreshControl extends LitElement {
@@ -10,7 +11,8 @@ export class AppRefreshControl extends LitElement {
@property({ attribute: false }) onRefresh?: () => void | Promise<void>;
@property({ attribute: false }) onReload?: () => void;
@state() private menuOpen = false;
@state() private menuStyle = "";
private menuStyle = "";
private menuPortal: HTMLDivElement | undefined;
private longPressTimer: number | undefined;
private suppressNextClick = false;
@@ -24,6 +26,7 @@ export class AppRefreshControl extends LitElement {
document.removeEventListener("click", this.onDocumentClick);
document.removeEventListener("keydown", this.onDocumentKeyDown);
this.clearLongPressTimer();
this.removePortalMenu();
super.disconnectedCallback();
}
@@ -44,17 +47,6 @@ export class AppRefreshControl extends LitElement {
@pointercancel=${() => { this.clearLongPressTimer(); }}
@pointerleave=${() => { this.clearLongPressTimer(); }}
>${this.renderRefreshIcon()}</button>
${this.renderMenu()}
`;
}
private renderMenu() {
if (!this.menuOpen) return null;
return html`
<div class="app-refresh-menu" role="menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
<button role="menuitem" @click=${() => { this.refresh(); }}>Refresh app data</button>
<button role="menuitem" @click=${() => { this.reload(); }}>Full page reload</button>
</div>
`;
}
@@ -100,7 +92,8 @@ export class AppRefreshControl extends LitElement {
};
private readonly onDocumentClick = (event: MouseEvent): void => {
if (event.composedPath().includes(this)) return;
const path = event.composedPath();
if (path.includes(this) || (this.menuPortal !== undefined && path.includes(this.menuPortal))) return;
this.closeMenu();
};
@@ -114,11 +107,13 @@ export class AppRefreshControl extends LitElement {
private openMenu(target: EventTarget | null): void {
this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
this.menuOpen = true;
this.renderPortalMenu();
}
private closeMenu(): void {
this.menuOpen = false;
this.suppressNextClick = false;
this.removePortalMenu();
}
private refresh(): void {
@@ -137,15 +132,96 @@ export class AppRefreshControl extends LitElement {
this.longPressTimer = undefined;
}
private renderPortalMenu(): void {
const ownerDocument = this.ownerDocument;
ensurePortalMenuStyles(ownerDocument);
const menu = this.menuPortal ?? ownerDocument.createElement("div");
this.menuPortal = menu;
menu.className = "pi-web-app-refresh-menu-portal";
menu.setAttribute("role", "menu");
menu.setAttribute("style", this.menuStyle);
menu.replaceChildren(
this.createPortalMenuButton("Refresh app data", () => { this.refresh(); }),
this.createPortalMenuButton("Full page reload", () => { this.reload(); }),
);
menu.addEventListener("click", this.onPortalMenuClick);
if (!menu.isConnected) ownerDocument.body.append(menu);
}
private createPortalMenuButton(label: string, onClick: () => void): HTMLButtonElement {
const button = this.ownerDocument.createElement("button");
button.type = "button";
button.setAttribute("role", "menuitem");
button.textContent = label;
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick();
});
return button;
}
private removePortalMenu(): void {
this.menuPortal?.removeEventListener("click", this.onPortalMenuClick);
this.menuPortal?.remove();
this.menuPortal = undefined;
}
private readonly onPortalMenuClick = (event: MouseEvent): void => {
event.stopPropagation();
};
static override styles = css`
:host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
:host, :host * { -webkit-user-select: none; user-select: none; }
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; }
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
.app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; }
.app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; }
.app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
`;
}
function ensurePortalMenuStyles(ownerDocument: Document): void {
if (ownerDocument.getElementById(REFRESH_MENU_PORTAL_STYLE_ID) !== null) return;
const style = ownerDocument.createElement("style");
style.id = REFRESH_MENU_PORTAL_STYLE_ID;
style.textContent = `
.pi-web-app-refresh-menu-portal {
position: fixed;
z-index: 2147483647;
box-sizing: border-box;
min-width: min(170px, calc(100vw - 16px));
overflow: auto;
padding: 4px;
border: 1px solid var(--pi-border);
border-radius: 8px;
background: var(--pi-surface);
color: var(--pi-text);
box-shadow: 0 8px 24px var(--pi-shadow);
overflow-wrap: anywhere;
font: 14px system-ui, sans-serif;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.pi-web-app-refresh-menu-portal button {
display: block;
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--pi-text);
padding: 7px 9px;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
font: inherit;
cursor: pointer;
}
.pi-web-app-refresh-menu-portal button:hover,
.pi-web-app-refresh-menu-portal button:focus {
background: var(--pi-selection-bg);
}
`;
ownerDocument.head.append(style);
}
@@ -0,0 +1,190 @@
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft";
@customElement("settings-general-panel")
export class SettingsGeneralPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private draft: ConfigDraft = emptyConfigDraft();
@state() private localError = "";
protected override willUpdate(changed: PropertyValues<this>): void {
if (changed.has("configResponse") && this.configResponse !== undefined) {
this.draft = draftFromConfig(this.configResponse.config);
this.localError = "";
}
}
override render(): TemplateResult {
const config = this.configResponse;
return html`
<div class="section-heading">
<div>
<h2>General configuration</h2>
<p>Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
</div>
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}>
<label class="field">
<span class="field-heading">
<span>Host</span>
${this.renderOverrideBadge("host")}
</span>
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}>
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Port</span>
${this.renderOverrideBadge("port")}
</span>
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}>
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
</label>
<div class="field">
<span class="field-heading">
<span>Allowed hosts</span>
${this.renderOverrideBadge("allowedHosts")}
</span>
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
<option value="list">Only listed hosts</option>
<option value="all">Allow every host</option>
</select>
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local&#10;192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div>
${this.renderEffectiveConfig()}
<footer class="form-actions">
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button>
</footer>
</form>
`}
`;
}
private renderMessages(): TemplateResult | null {
const error = this.localError || this.error;
if (error !== "") return html`<div class="message error-message">${error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
return null;
}
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
if (this.configResponse?.envOverrides[key] !== true) return null;
return html`<span class="override-badge">environment override</span>`;
}
private renderEffectiveConfig(): TemplateResult {
const effective = this.configResponse?.effectiveConfig ?? {};
return html`
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
</dl>
</section>
`;
}
private async saveConfig(event: Event): Promise<void> {
event.preventDefault();
this.localError = "";
try {
await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {}));
} catch (error) {
this.localError = errorMessage(error);
}
}
private updateDraft(patch: Partial<ConfigDraft>): void {
this.draft = { ...this.draft, ...patch };
this.localError = "";
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; }
button, input, select, textarea { 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 { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { 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 { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
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; }
.config-path-card small, .field small { color: var(--pi-muted); }
.config-form { display: grid; gap: 14px; }
.field { display: grid; gap: 7px; }
.field-heading { display: flex; align-items: center; gap: 8px; }
input, select, textarea { 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: 9px 10px; outline: none; }
input:focus, select:focus, textarea:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
textarea { resize: vertical; min-height: 94px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
textarea:disabled { opacity: .55; }
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
.effective-card { display: grid; gap: 10px; }
.effective-card dl { display: grid; gap: 8px; margin: 0; }
.effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.muted { color: var(--pi-muted); }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; }
}
`;
}
function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string | TemplateResult {
if (value === true) return "Any host";
if (Array.isArray(value)) return value.length === 0 ? html`<span class="muted">None listed</span>` : value.join(", ");
return html`<span class="muted">Unset</span>`;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
function selectValue(event: Event): string {
return event.target instanceof HTMLSelectElement ? event.target.value : "";
}
function textAreaValue(event: Event): string {
return event.target instanceof HTMLTextAreaElement ? event.target.value : "";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,99 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api";
@customElement("settings-plugins-panel")
export class SettingsPluginsPanel extends LitElement {
@property({ attribute: false }) pluginsResponse: PiWebPluginsResponse | undefined;
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
override render(): TemplateResult {
const plugins = this.pluginsResponse?.plugins ?? [];
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>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<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`
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
`}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage} Reload the browser tab to apply plugin changes.</div>`;
return null;
}
private renderPlugin(plugin: PiWebPluginInfo): TemplateResult {
const configured = this.configResponse?.config.plugins?.[plugin.id];
const configuredState = configured?.enabled === false ? "Config disabled" : configured?.enabled === true ? "Config enabled" : "Default enabled";
return html`
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
<div class="plugin-main">
<strong>${plugin.id}</strong>
<small>${plugin.source} · ${plugin.scope}</small>
<small>${configuredState}</small>
</div>
<label class="toggle">
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
</label>
</article>
`;
}
private async togglePlugin(plugin: PiWebPluginInfo, event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement ? event.target.checked : plugin.enabled;
await this.onTogglePlugin?.(plugin.id, enabled);
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
p { 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; }
.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; }
.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); }
.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; }
.plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.plugin-card.disabled { opacity: .75; }
.plugin-main { min-width: 0; display: grid; gap: 3px; }
.plugin-main strong, .plugin-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plugin-main small { color: var(--pi-muted); }
.toggle { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.toggle input { width: 18px; height: 18px; accent-color: var(--pi-accent); }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.plugin-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
.toggle { justify-self: start; }
}
`;
}
@@ -0,0 +1,124 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { AppAction } from "../../actions";
import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api";
import { formatShortcut } from "../../keyboardShortcuts";
@customElement("settings-shortcuts-panel")
export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) actions: AppAction[] = [];
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
override render(): TemplateResult {
const groups = shortcutGroups(this.actions);
return html`
<div class="section-heading">
<div>
<h2>Keyboard shortcuts</h2>
<p>Review registered app actions and the shortcut config that will become editable here. Manual config entries use action ids and can override a default shortcut or set it to <code>null</code> to disable it.</p>
</div>
</div>
<div class="shortcut-note">Config key: <code>shortcuts</code>. Example: <code>{ "core:view.chat": "mod+1", "core:session.stop": null }</code></div>
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
<section class="shortcut-group">
<h3>${group.name}</h3>
<div class="shortcut-list">
${group.actions.map((action) => this.renderShortcutRow(action))}
</div>
</section>
`)}
`;
}
private renderShortcutRow(action: AppAction): TemplateResult {
const shortcuts = this.configResponse?.config.shortcuts;
const configured = shortcutPreference(action.id, shortcuts);
const shortcut = configured === null ? undefined : configured ?? action.shortcut;
const state = shortcutState(action, shortcuts);
return html`
<div class="shortcut-row">
<div class="shortcut-main">
<strong>${action.title}</strong>
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
<small class="shortcut-id">${action.id}</small>
</div>
<div class="shortcut-value">
${shortcut !== undefined && shortcut !== "" ? html`<kbd>${formatShortcut(shortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
<small class=${state}>${shortcutStateLabel(state)}</small>
</div>
</div>
`;
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; }
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; }
.loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .shortcut-note { color: var(--pi-muted); }
.shortcut-note { margin-bottom: 14px; }
.shortcut-group { margin: 0 0 16px; }
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; }
.shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-main { min-width: 0; display: grid; gap: 3px; }
.shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shortcut-main small { color: var(--pi-muted); }
.shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; }
kbd { justify-self: end; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.unassigned { justify-self: end; color: var(--pi-muted); font-size: 12px; }
.shortcut-value small { color: var(--pi-muted); font-size: 11px; }
.shortcut-value small.custom { color: var(--pi-accent); }
.shortcut-value small.disabled { color: var(--pi-warning); }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.shortcut-value { justify-self: start; justify-items: start; }
kbd, .unassigned { justify-self: start; }
}
`;
}
type ShortcutState = "default" | "custom" | "disabled" | "unassigned";
function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] {
const grouped = new Map<string, AppAction[]>();
for (const action of [...actions].sort(compareActions)) {
const group = action.group ?? "Other";
grouped.set(group, [...(grouped.get(group) ?? []), action]);
}
return [...grouped.entries()].map(([name, groupActions]) => ({ name, actions: groupActions }));
}
function compareActions(left: AppAction, right: AppAction): number {
return (left.group ?? "Other").localeCompare(right.group ?? "Other") || left.title.localeCompare(right.title);
}
function shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | undefined): string | null | undefined {
if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined;
return shortcuts[actionId];
}
function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState {
const configured = shortcutPreference(action.id, shortcuts);
if (configured === null) return "disabled";
if (configured !== undefined) return "custom";
return action.shortcut === undefined || action.shortcut === "" ? "unassigned" : "default";
}
function shortcutStateLabel(state: ShortcutState): string {
switch (state) {
case "default": return "Default";
case "custom": return "Config override";
case "disabled": return "Config disabled";
case "unassigned": return "No default";
}
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
describe("settings config drafts", () => {
it("converts PI WEB config values to editable general settings drafts", () => {
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({
host: "0.0.0.0",
port: "8504",
allowedHostsMode: "list",
allowedHostsText: "example.local\n192.168.1.20",
});
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
});
it("converts drafts back to config while preserving shortcut and plugin preferences", () => {
expect(configFromDraft({
host: " 127.0.0.1 ",
port: "9000",
allowedHostsMode: "list",
allowedHostsText: "example.local, 192.168.1.20\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({
host: "127.0.0.1",
port: 9000,
allowedHosts: ["example.local", "192.168.1.20"],
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
plugins: { info: { enabled: false } },
});
});
});
@@ -0,0 +1,42 @@
import type { PiWebConfigValues } from "../../api";
export interface ConfigDraft {
host: string;
port: string;
allowedHostsMode: "list" | "all";
allowedHostsText: string;
}
export function emptyConfigDraft(): ConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
}
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
return {
host: config.host ?? "",
port: config.port === undefined ? "" : String(config.port),
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
};
}
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config: PiWebConfigValues = {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
};
const host = draft.host.trim();
const port = draft.port.trim();
if (host !== "") config.host = host;
if (port !== "") {
const parsed = Number(port);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("Port must be an integer from 1 to 65535.");
config.port = parsed;
}
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
return config;
}
function parseAllowedHostsText(value: string): string[] {
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
}
+8
View File
@@ -79,6 +79,14 @@ export function createCoreActions(): PluginAction[] {
group: "Preferences",
run: (context) => { context.openThemePicker(); },
},
{
id: "settings.open",
title: "Open Settings",
description: "Manage PI WEB configuration and keyboard shortcuts",
shortcut: "mod+,",
group: "Preferences",
run: (context) => { context.piWebInternal?.openSettings?.(); },
},
{
id: "app.refresh-data",
title: "Refresh App Data",
+30 -2
View File
@@ -18,6 +18,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
getCommandRun: vi.fn(),
open: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`terminal.open:${options?.terminalId ?? ""}`); }),
},
openSettings: vi.fn(() => { calls.push("openSettings"); }),
},
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
@@ -54,6 +55,31 @@ describe("PluginRegistry", () => {
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
});
it("provides html and svg helpers to plugin activation", () => {
const registry = new PluginRegistry();
registry.register({
id: "example",
plugin: {
apiVersion: 1,
name: "Example",
activate: ({ html, svg }) => ({
contributions: {
workspacePanels: [
{
id: "workspace.logs",
title: "Logs",
icon: svg`<svg viewBox="0 0 24 24"><path d="M4 6h16"></path></svg>`,
render: () => html`<p>Logs</p>`,
},
],
},
}),
},
});
expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined();
});
it("rejects duplicate ids within the same namespace", () => {
const registry = new PluginRegistry();
@@ -146,7 +172,7 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["refreshGit"]);
});
it("routes app refresh and reload actions through the runtime context", () => {
it("routes app refresh, reload, and settings actions through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext();
@@ -154,8 +180,9 @@ describe("PluginRegistry", () => {
void actions.find((candidate) => candidate.id === "core:app.refresh-data")?.run();
void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run();
void actions.find((candidate) => candidate.id === "core:settings.open")?.run();
expect(calls).toEqual(["refreshAppData", "reloadPage"]);
expect(calls).toEqual(["refreshAppData", "reloadPage", "openSettings"]);
});
it("exposes terminal navigation as a shortcut-backed action", () => {
@@ -179,6 +206,7 @@ describe("PluginRegistry", () => {
expect(shortcuts).toEqual([
["core:actions.show", "mod+k"],
["core:settings.open", "mod+,"],
["core:view.chat", "mod+1"],
["core:view.files", "mod+2"],
["core:view.git", "mod+3"],
+2 -2
View File
@@ -1,4 +1,4 @@
import { html } from "lit";
import { html, svg } from "lit";
import type { AppState } from "../appState";
import type { Workspace } from "../api";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
@@ -31,7 +31,7 @@ export class PluginRegistry {
const apiVersion: unknown = plugin.apiVersion;
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html });
const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg });
const contributions = result.contributions;
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
+7
View File
@@ -2,10 +2,12 @@ import type { TemplateResult } from "lit";
import type { AppAction } from "../actions";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
import type { AppState } from "../appState";
import type { SettingsSection } from "../settingsRoute";
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
export type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export interface PiWebPluginRegistration {
id: PluginId;
@@ -22,6 +24,7 @@ export interface PluginActivationContext {
apiVersion: 1;
pluginId: PluginId;
html: HtmlTemplateTag;
svg: SvgTemplateTag;
}
export interface PluginActivationResult {
@@ -38,6 +41,7 @@ export interface PluginContributions {
export interface PiWebInternalRuntimeContext {
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
openSettings?: (section?: SettingsSection) => void;
}
export interface TerminalCommandRunsInternalRuntime {
@@ -120,9 +124,12 @@ export interface WorkspacePanelContext {
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void;
}
export type WorkspacePanelIcon = TemplateResult;
export interface WorkspacePanelContribution {
id: LocalContributionId;
title: string;
icon?: WorkspacePanelIcon;
order?: number;
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseSettingsSection, readSettingsSection, writeSettingsSection } from "./settingsRoute";
const originalWindow = globalThis.window;
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true });
});
function installWindow(href: string): { pushed: string[]; replaced: string[] } {
const url = new URL(href);
const pushed: string[] = [];
const replaced: string[] = [];
const fakeWindow = {
location: {
href: url.href,
pathname: url.pathname,
search: url.search,
hash: url.hash,
},
history: {
pushState: vi.fn((_state: object, _title: string, next: URL | string) => {
pushed.push(String(next));
}),
replaceState: vi.fn((_state: object, _title: string, next: URL | string) => {
replaced.push(String(next));
}),
},
};
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
return { pushed, replaced };
}
describe("settings route helpers", () => {
it("parses supported settings deep links and aliases", () => {
expect(parseSettingsSection("general")).toBe("general");
expect(parseSettingsSection("plugins")).toBe("plugins");
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
expect(parseSettingsSection("unknown")).toBeUndefined();
});
it("reads the settings section from the current URL", () => {
installWindow("http://localhost/app?project=p1&settings=shortcuts");
expect(readSettingsSection()).toBe("shortcuts");
});
it("writes settings deep links while preserving other route fields", () => {
const { pushed } = installWindow("http://localhost/app?project=p1#bottom");
writeSettingsSection("general");
expect(pushed).toEqual(["http://localhost/app?project=p1&settings=general#bottom"]);
});
it("removes settings deep links with replace when closing", () => {
const { replaced } = installWindow("http://localhost/app?project=p1&settings=general#bottom");
writeSettingsSection(undefined, { replace: true });
expect(replaced).toEqual(["http://localhost/app?project=p1#bottom"]);
});
});
+23
View File
@@ -0,0 +1,23 @@
export type SettingsSection = "general" | "plugins" | "shortcuts";
export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
}
export function writeSettingsSection(section: SettingsSection | undefined, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href);
if (section === undefined) url.searchParams.delete("settings");
else url.searchParams.set("settings", section);
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next === current) return;
if (options?.replace === true) window.history.replaceState({}, "", url);
else window.history.pushState({}, "", url);
}
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
if (value === "general") return "general";
if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
return undefined;
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { AppAction } from "./actions";
import { applyShortcutPreferences } from "./shortcutPreferences";
const noop = () => undefined;
describe("shortcut preferences", () => {
it("keeps default shortcuts when there is no matching preference", () => {
const actions = [action({ id: "core:view.chat", shortcut: "mod+1" })];
expect(applyShortcutPreferences(actions, { "core:view.files": "mod+2" })).toEqual(actions);
});
it("overrides action shortcuts by action id", () => {
expect(applyShortcutPreferences([
action({ id: "core:view.chat", shortcut: "mod+1" }),
], { "core:view.chat": "mod+shift+1" })).toEqual([
action({ id: "core:view.chat", shortcut: "mod+shift+1" }),
]);
});
it("removes shortcuts with null preferences", () => {
expect(applyShortcutPreferences([
action({ id: "core:view.chat", shortcut: "mod+1" }),
], { "core:view.chat": null })).toEqual([
action({ id: "core:view.chat" }),
]);
});
});
function action(patch: Partial<AppAction>): AppAction {
return { id: "action", title: "Action", run: noop, ...patch };
}
+21
View File
@@ -0,0 +1,21 @@
import type { AppAction } from "./actions";
import type { PiWebShortcutConfig } from "./api";
export function applyShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] {
if (shortcuts === undefined) return actions;
return actions.map((action) => applyShortcutPreference(action, shortcuts));
}
export function applyShortcutPreference(action: AppAction, shortcuts: PiWebShortcutConfig): AppAction {
if (!Object.hasOwn(shortcuts, action.id)) return action;
const shortcut = shortcuts[action.id];
if (shortcut === undefined) return action;
if (shortcut === null) return withoutShortcut(action);
return { ...action, shortcut };
}
function withoutShortcut(action: AppAction): AppAction {
const copy = { ...action };
delete copy.shortcut;
return copy;
}
+44
View File
@@ -0,0 +1,44 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadPiWebConfig, savePiWebConfig } from "./config.js";
let tempDir: string;
let configPath: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-config-test-"));
configPath = join(tempDir, "config.json");
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("PI WEB config persistence", () => {
it("writes and reads the configured PI WEB config path", () => {
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions());
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } });
expect(loadPiWebConfig(testOptions())).toEqual(saved);
});
it("preserves unrelated config keys while replacing managed keys", async () => {
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, future: { enabled: true } }, null, 2)}\n`, "utf8");
savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions());
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] });
});
it("rejects invalid plugin config", async () => {
await writeFile(configPath, `${JSON.stringify({ plugins: { info: { enabled: "no" } } }, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans");
});
});
function testOptions(): { env: NodeJS.ProcessEnv } {
return { env: { PI_WEB_CONFIG: configPath } };
}
+63 -7
View File
@@ -1,12 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
export interface PiWebConfig {
host?: string;
port?: number;
allowedHosts?: string[] | true;
}
export type PiWebConfig = PiWebConfigValues;
export interface LoadedPiWebConfig {
path: string;
@@ -14,7 +12,7 @@ export interface LoadedPiWebConfig {
config: PiWebConfig;
}
interface LoadOptions {
export interface LoadOptions {
env?: NodeJS.ProcessEnv;
cwd?: string;
}
@@ -69,11 +67,46 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
};
}
export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): LoadedPiWebConfig {
const env = options.env ?? process.env;
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
const existing = readExistingConfigObject(path);
delete existing["host"];
delete existing["port"];
delete existing["allowedHosts"];
delete existing["shortcuts"];
delete existing["plugins"];
const merged = { ...existing, ...piWebConfigRecord(normalized) };
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
return { path, exists: true, config: normalized };
}
function readExistingConfigObject(path: string): Record<string, unknown> {
if (!existsSync(path)) return {};
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`);
return parsed;
}
function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
return {
...(config.host !== undefined ? { host: config.host } : {}),
...(config.port !== undefined ? { port: config.port } : {}),
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
};
}
function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebConfig {
return {
...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}),
...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}),
...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}),
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
};
}
@@ -101,6 +134,29 @@ function parseAllowedHostsEnv(value: string): string[] | true {
return value.split(",").map((host) => host.trim()).filter((host) => host !== "");
}
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) {
throw new Error(`PI WEB config shortcut values must be non-empty strings or null: ${path}`);
}
return [actionId, shortcut];
}));
}
function parsePlugins(value: unknown, path: string): NonNullable<PiWebConfigValues["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error(`PI WEB config plugins must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
if (!isPiWebPluginId(pluginId)) throw new Error(`PI WEB config plugin ids must match ${piWebPluginIdPattern.source}: ${path}`);
if (!isRecord(config) || Array.isArray(config)) throw new Error(`PI WEB config plugin entries must be objects: ${path}`);
const enabled = config["enabled"];
if (enabled !== undefined && typeof enabled !== "boolean") throw new Error(`PI WEB config plugin enabled values must be booleans: ${path}`);
const settings = config["settings"];
if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error(`PI WEB config plugin settings must be objects: ${path}`);
return [pluginId, config];
}));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+5
View File
@@ -50,6 +50,7 @@ beforeEach(async () => {
sessionDaemon: fakeSessionDaemon(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
},
clientDist: false,
@@ -288,6 +289,10 @@ describe("buildApp", () => {
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] });
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
expect(pluginsResponse.statusCode).toBe(200);
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] });
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
+5 -1
View File
@@ -14,6 +14,7 @@ import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
@@ -25,7 +26,8 @@ export interface AppDependencies {
workspaces?: WorkspaceService;
machines?: MachineService;
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
}
@@ -100,6 +102,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/status", async () => getPiWebStatus());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
app.get("/api/plugins", async () => piWebPlugins.plugins());
registerConfigRoutes(app, deps.config);
registerMachineRoutes(app, machines);
+69
View File
@@ -0,0 +1,69 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
let app: FastifyInstance;
let savedConfig: PiWebConfigValues;
let service: PiWebConfigService;
beforeEach(async () => {
savedConfig = { host: "127.0.0.1", port: 8504, allowedHosts: [] };
service = {
read: vi.fn(() => responseFor(savedConfig, true)),
write: vi.fn((config: PiWebConfigValues) => {
savedConfig = config;
return responseFor(savedConfig, true);
}),
};
app = Fastify({ logger: false });
registerConfigRoutes(app, service);
await app.ready();
});
afterEach(async () => {
await app.close();
});
describe("config routes", () => {
it("returns the PI WEB config contract", async () => {
const response = await app.inject({ method: "GET", url: "/api/config" });
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>()).toEqual(responseFor(savedConfig, true));
});
it("updates config through the service", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } },
});
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } });
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
});
it("rejects invalid config payloads before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { host: 42 } },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
});
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false },
};
}
+126
View File
@@ -0,0 +1,126 @@
import type { FastifyInstance } from "fastify";
import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";
export interface PiWebConfigService {
read: () => PiWebConfigResponse | Promise<PiWebConfigResponse>;
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
}
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
return {
read: () => currentPiWebConfigResponse(options),
write: (config) => {
savePiWebConfig(config, options);
return currentPiWebConfigResponse(options);
},
};
}
export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse {
const loaded = loadPiWebConfig(options);
const effective = effectivePiWebConfig(options);
const env = options.env ?? process.env;
return {
path: loaded.path,
exists: loaded.exists,
config: loaded.config,
effectiveConfig: effective.config,
envOverrides: piWebConfigEnvOverrides(env),
};
}
export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
app.get("/api/config", async (_request, reply) => {
try {
return await service.read();
} catch (error) {
return reply.code(500).send({ error: errorMessage(error) });
}
});
app.put<{ Body: { config?: unknown } | undefined }>("/api/config", async (request, reply) => {
try {
return await service.write(parseConfigRequest(request.body?.config));
} catch (error) {
const status = isConfigValidationError(error) ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
});
}
function parseConfigRequest(value: unknown): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
const config: PiWebConfig = {};
const host = value["host"];
const port = value["port"];
const allowedHosts = value["allowedHosts"];
const shortcuts = value["shortcuts"];
const plugins = value["plugins"];
if (host !== undefined) {
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
config.host = host;
}
if (port !== undefined) {
if (typeof port !== "number") throw new Error("PI WEB config port must be a number");
config.port = port;
}
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
return config;
}
function parseAllowedHostsRequest(value: unknown): string[] | true {
if (value === true) return true;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
throw new Error("PI WEB config allowedHosts must be true or an array of strings");
}
return value;
}
function parseShortcutsRequest(value: unknown): Record<string, string | null> {
if (!isRecord(value)) throw new Error("PI WEB config shortcuts must be an object");
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) throw new Error("PI WEB config shortcut values must be non-empty strings or null");
return [actionId, shortcut];
}));
}
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
if (!isPiWebPluginId(pluginId)) throw new Error("PI WEB config plugin ids are invalid");
if (!isRecord(config) || Array.isArray(config)) throw new Error("PI WEB config plugin entries must be objects");
const enabled = config["enabled"];
if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("PI WEB config plugin enabled values must be booleans");
const settings = config["settings"];
if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("PI WEB config plugin settings must be objects");
return [pluginId, config];
}));
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
return {
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
};
}
function isEnvSet(value: string | undefined): boolean {
return value !== undefined && value !== "";
}
function isConfigValidationError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith("PI WEB config");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+25
View File
@@ -88,6 +88,31 @@ describe("PiWebPluginService", () => {
await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined();
});
it("filters disabled plugins from the manifest while reporting them through plugin status", async () => {
await writePlugin(join(tempDir, "plugins", "enabled"), {
packageJson: { piWeb: { plugins: [{ id: "enabled", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(join(tempDir, "plugins", "disabled"), {
packageJson: { piWeb: { plugins: [{ id: "disabled", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({
roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }],
packageProvider: false,
configProvider: () => ({ plugins: { disabled: { enabled: false, settings: { hidden: true } } } }),
});
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "enabled" }] });
await expect(service.plugins()).resolves.toMatchObject({
plugins: [
{ id: "disabled", enabled: false },
{ id: "enabled", enabled: true },
],
});
});
it("skips duplicate plugin ids", async () => {
await writePlugin(join(tempDir, "plugins", "one"), {
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
+35 -14
View File
@@ -3,15 +3,22 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { piWebDataDir } from "../config.js";
import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js";
import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";
const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
export type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
export interface PiWebPluginManifest {
plugins: { id: string; module: string; source: string; scope: PiWebPluginScope }[];
plugins: PiWebPluginManifestEntry[];
}
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
export interface PiWebPluginManifestEntry {
id: string;
module: string;
source: string;
scope: PiWebPluginScope;
}
export interface ConfiguredPiPackage {
source: string;
@@ -38,6 +45,7 @@ interface PiWebPluginServiceOptions {
cwd?: string;
agentDir?: string;
packageProvider?: PiPackageProvider | false;
configProvider?: () => PiWebConfig;
}
interface LocalPluginRoot {
@@ -80,28 +88,31 @@ export class DefaultPiPackageProvider implements PiPackageProvider {
export class PiWebPluginService {
private readonly roots: LocalPluginRoot[];
private readonly packageProvider: PiPackageProvider | undefined;
private readonly configProvider: () => PiWebConfig;
constructor(options: PiWebPluginServiceOptions = {}) {
const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getAgentDir();
this.roots = options.roots ?? defaultPluginRoots(cwd);
this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir);
this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config);
}
async manifest(): Promise<PiWebPluginManifest> {
const plugins = await this.discoverPlugins();
return {
plugins: plugins.map((plugin) => ({
id: plugin.id,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
source: plugin.source,
scope: plugin.scope,
})),
plugins: (await this.plugins()).plugins
.filter((plugin) => plugin.enabled)
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })),
};
}
async plugins(): Promise<PiWebPluginsResponse> {
const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]);
return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) };
}
async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
if (!pluginIdPattern.test(pluginId)) return undefined;
if (!isPiWebPluginId(pluginId)) return undefined;
const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId);
if (plugin === undefined) return undefined;
@@ -118,6 +129,16 @@ export class PiWebPluginService {
return { content: await readFile(realAsset), contentType: contentTypeFor(realAsset) };
}
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
return {
id: plugin.id,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
source: plugin.source,
scope: plugin.scope,
enabled: config.plugins?.[plugin.id]?.enabled !== false,
};
}
private async discoverPlugins(): Promise<PluginRecord[]> {
const records = new Map<string, PluginRecord>();
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
@@ -173,7 +194,7 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
const plugins: PluginRecord[] = [];
for (const entry of entries) {
if (!pluginIdPattern.test(entry.name)) continue;
if (!isPiWebPluginId(entry.name)) continue;
const pluginRoot = join(root.path, entry.name);
const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined;
if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue;
@@ -236,7 +257,7 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
if (!isRecord(entry)) throw new Error(`PI WEB plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
const id = entry["id"];
const module = entry["module"];
if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
return { id, module };
});
+46
View File
@@ -22,6 +22,52 @@ export interface MachineHealth {
error?: string;
}
export type PiWebShortcutConfig = Record<string, string | null>;
export type PiWebPluginSettings = Record<string, unknown>;
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
export interface PiWebPluginConfig {
enabled?: boolean;
settings?: PiWebPluginSettings;
[key: string]: unknown;
}
export interface PiWebConfigValues {
host?: string;
port?: number;
allowedHosts?: string[] | true;
shortcuts?: PiWebShortcutConfig;
plugins?: PiWebPluginConfigMap;
}
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
export interface PiWebPluginInfo {
id: string;
module: string;
source: string;
scope: PiWebPluginScope;
enabled: boolean;
}
export interface PiWebPluginsResponse {
plugins: PiWebPluginInfo[];
}
export interface PiWebConfigEnvOverrides {
host: boolean;
port: boolean;
allowedHosts: boolean;
}
export interface PiWebConfigResponse {
path: string;
exists: boolean;
config: PiWebConfigValues;
effectiveConfig: PiWebConfigValues;
envOverrides: PiWebConfigEnvOverrides;
}
export interface Project {
id: string;
name: string;
+5
View File
@@ -0,0 +1,5 @@
export const piWebPluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
export function isPiWebPluginId(value: string): boolean {
return piWebPluginIdPattern.test(value);
}