feat(settings): unify settings panel layout

This commit is contained in:
Federico Jaramillo Martinez
2026-07-02 13:28:03 +02:00
parent 64b2b32705
commit b61a9c0c54
15 changed files with 1140 additions and 306 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Standardize Settings tabs so descriptions, notices, and controls render in a consistent order, with unavailable remote settings hiding blocked controls.
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
@@ -146,12 +147,16 @@ describe("settings-dialog session daemon machine targeting", () => {
});
describe("settings-dialog general settings machine targeting", () => {
it("describes the General tab as gateway server plus selected-machine file/upload settings", () => {
it("renders the active settings panel without the old global scope note", () => {
const dialog = new SettingsDialog();
dialog.section = "general";
dialog.machine = remoteMachine;
expect(callDialogMethod(dialog, "settingsScopeMessage")).toBe("Gateway server config and selected machine file/upload config: Lab Mac (remote machine).");
const strings = collectTemplateStrings(dialog.render()).join("");
expect(strings).toContain("<settings-general-panel");
expect(strings).not.toContain("scope-note");
expect(strings).not.toContain("This tab edits:");
});
it("keeps gateway server config saves on the gateway config endpoint", async () => {
@@ -533,6 +538,43 @@ function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args
return typeof value === "function";
}
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
@@ -119,7 +119,6 @@ export class SettingsDialog extends LitElement {
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
</nav>
<main class="settings-content">
${this.renderScopeNote()}
${this.renderActiveSection()}
</main>
</div>
@@ -218,22 +217,6 @@ export class SettingsDialog extends LitElement {
`;
}
private renderScopeNote(): TemplateResult {
return html`
<div class="scope-note" role="note">
<strong>This tab edits:</strong> ${this.settingsScopeMessage()}
</div>
`;
}
private settingsScopeMessage(): string {
if (this.section === "packages") return `Selected machine packages: ${piPackageTargetLabel(this.packageTarget())}.`;
if (this.section === "sessiond") return `Selected machine session-daemon config: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
if (this.section === "plugins") return `Selected machine PI WEB plugin enablement: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
if (this.section === "shortcuts") return "Local gateway keyboard shortcuts.";
return `Gateway server config and selected machine file/upload config: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
}
private navigate(section: SettingsSection): void {
this.onNavigate?.(section);
}
@@ -657,7 +640,6 @@ export class SettingsDialog extends LitElement {
.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; }
.scope-note { margin-bottom: 14px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); padding: 10px 12px; line-height: 1.45; }
@media (max-width: 760px) {
.backdrop { padding: 0; place-items: stretch; }
@@ -15,6 +15,7 @@ describe("settings-general-panel copy", () => {
const strings = collectTemplateStrings(template).join("");
const values = collectTemplateValues(template);
expect(strings).toContain("<settings-panel-frame");
expect(strings).toContain("Gateway server fields edit this local gateway. File access and upload defaults edit ");
expect(strings).toContain("Host, port, and allowed hosts are saved in the gateway config.");
expect(strings).toContain("External filesystem roots and upload defaults are saved on ");
@@ -30,9 +31,27 @@ describe("settings-general-panel copy", () => {
const template = panel.render();
const values = collectTemplateValues(template);
expect(values).toContain("Save gateway server config");
expect(values).not.toContain("Save file/upload config");
expect(values).toContain("Selected-machine file access config is unavailable. Reload before saving file/upload settings.");
expect(values).toContain("Failed to load file access/upload config from Lab Mac (remote machine): unsupported");
});
it("uses frame notices for saved and gateway messages while keeping selected-machine errors scoped", () => {
const panel = new SettingsGeneralPanel();
panel.error = "Gateway failed";
panel.machineError = "Selected-machine failed";
panel.savedMessage = "Config saved.";
const values = collectTemplateValues(panel.render());
const notices = values.find(isSettingsNoticeArray);
expect(notices).toEqual([
{ type: "error", title: "Gateway server", content: "Gateway failed" },
{ type: "success", content: "Config saved." },
]);
expect(values).toContain("Selected-machine failed");
});
});
describe("settings-general-panel save payloads", () => {
@@ -190,6 +209,12 @@ function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNoticeArray(value: unknown): value is readonly { type: string; content: unknown; title?: string }[] {
return Array.isArray(value)
&& value.length > 0
&& value.every((item: unknown) => typeof item === "object" && item !== null && typeof Reflect.get(item, "type") === "string" && Reflect.has(item, "content"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
@@ -1,6 +1,8 @@
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { DEFAULT_WORKSPACE_UPLOADS_FOLDER, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import {
emptyGatewayServerConfigDraft,
emptyMachineAccessConfigDraft,
@@ -12,6 +14,10 @@ import {
type MachineAccessConfigDraft,
} from "./settingsConfigDraft";
function generalDescription(targetLabel: string): TemplateResult {
return html`Gateway server fields edit this local gateway. File access and upload defaults edit ${targetLabel}.`;
}
@customElement("settings-general-panel")
export class SettingsGeneralPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@@ -45,18 +51,19 @@ export class SettingsGeneralPanel extends LitElement {
override render(): TemplateResult {
return html`
<div class="section-heading">
<div>
<h2>General configuration</h2>
<p>Gateway server fields edit this local gateway. File access and upload defaults edit ${this.targetLabel}.</p>
<settings-panel-frame
heading="General configuration"
.description=${generalDescription(this.targetLabel)}
actionLabel="Reload"
.actionDisabled=${this.loading || this.machineLoading}
.notices=${this.panelNotices()}
.onAction=${() => { this.reloadAll(); }}
>
<div class="settings-sections">
${this.renderGatewayServerSettings()}
${this.renderSelectedMachineAccessSettings()}
</div>
<button class="secondary" ?disabled=${this.loading || this.machineLoading} @click=${() => { this.reloadAll(); }}>Reload</button>
</div>
${this.renderSavedMessage()}
<div class="settings-sections">
${this.renderGatewayServerSettings()}
${this.renderSelectedMachineAccessSettings()}
</div>
</settings-panel-frame>
`;
}
@@ -68,7 +75,6 @@ export class SettingsGeneralPanel extends LitElement {
<h3>Gateway server</h3>
<p>Host, port, and allowed hosts are saved in the gateway config. Address changes require the web service to restart before the running server binds to the new address.</p>
</div>
${this.renderGatewayMessages()}
${config === undefined && this.loading ? html`<div class="loading-card">Loading gateway configuration…</div>` : html`
<div class="config-path-card">
<span>Gateway config file</span>
@@ -161,15 +167,12 @@ export class SettingsGeneralPanel extends LitElement {
`;
}
private renderSavedMessage(): TemplateResult | null {
if (this.savedMessage === "") return null;
return html`<div class="message success-message">${this.savedMessage}</div>`;
}
private renderGatewayMessages(): TemplateResult | null {
const error = this.gatewayLocalError || this.error;
if (error === "") return null;
return html`<div class="message error-message">${error}</div>`;
private panelNotices(): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
const gatewayError = this.gatewayLocalError || this.error;
if (gatewayError !== "") notices.push({ type: "error", title: "Gateway server", content: gatewayError });
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
return notices;
}
private renderMachineMessages(): TemplateResult | null {
@@ -247,23 +250,19 @@ export class SettingsGeneralPanel extends LitElement {
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, .card-heading { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
.card-heading { display: grid; gap: 6px; min-width: 0; }
h3, p { margin: 0; }
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; }
.settings-sections { display: grid; gap: 14px; }
.settings-card, .message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.settings-card { display: grid; gap: 14px; }
.message { margin-bottom: 12px; }
.settings-card .message { margin-bottom: 0; }
.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; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
@@ -286,8 +285,6 @@ export class SettingsGeneralPanel extends LitElement {
.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; }
}
`;
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiPackageInfo } from "../../api";
import { SettingsPackagesPanel } from "./SettingsPackagesPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
import type { PiPackageManagementSupport, PiPackageTargetContext } from "./piPackageSettings";
const remoteTarget: PiPackageTargetContext = { id: "lab-mac", name: "Lab Mac", kind: "remote" };
const unsupportedMessage = "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.";
describe("settings-packages-panel layout", () => {
it("suppresses package controls and trust warnings when package management is unsupported", () => {
const panel = new SettingsPackagesPanel();
panel.targetMachine = remoteTarget;
panel.managementSupport = unsupportedPackageManagement();
panel.error = unsupportedMessage;
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain(unsupportedMessage);
expect(rendered).not.toContain("Trusted code warning");
expect(rendered).not.toContain("Pi package source");
expect(rendered).not.toContain("Configured Pi packages");
expect(rendered).not.toContain("No Pi packages configured");
});
it("shows a load-unavailable state instead of an empty package state when no response loaded", () => {
const panel = new SettingsPackagesPanel();
panel.targetMachine = remoteTarget;
panel.error = "Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac.",
"Pi package list unavailable for Lab Mac (remote machine). Use Reload to try again.",
]);
expect(rendered).not.toContain("No Pi packages configured");
expect(rendered).not.toContain("Trusted code warning");
expect(rendered).not.toContain("Pi package source");
expect(rendered).not.toContain("Configured Pi packages");
});
it("shows trust guidance, install controls, and empty state only after a package response loaded", () => {
const panel = new SettingsPackagesPanel();
panel.packagesResponse = { packages: [] };
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Pi packages",
"Managing Pi packages on ",
"local (local gateway)",
"Trusted code warning:",
"Pi package source",
"Configured Pi packages",
"No Pi packages configured in Pi settings on local (local gateway) yet.",
]);
expect(rendered).not.toContain("Pi package list unavailable");
});
it("orders package load errors before the trusted-code warning while preserving loaded data", () => {
const panel = new SettingsPackagesPanel();
panel.targetMachine = remoteTarget;
panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] };
panel.error = "Failed to refresh gateway PI WEB plugins after updating packages.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Failed to refresh gateway PI WEB plugins after updating packages.",
"Trusted code warning:",
"Pi package source",
"Configured Pi packages",
"npm:@acme/tools",
]);
});
});
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function unsupportedPackageManagement(): PiPackageManagementSupport {
return { state: "unsupported", message: unsupportedMessage };
}
function packageInfo(source: string): PiPackageInfo {
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
}
@@ -1,6 +1,8 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings";
@customElement("settings-packages-panel")
@@ -24,48 +26,70 @@ export class SettingsPackagesPanel extends LitElement {
const target = this.packageTarget;
const targetLabel = piPackageTargetLabel(target);
const packageManagementUnavailable = this.packageManagementUnavailable;
const showPackageControls = this.packagesResponse !== undefined && !packageManagementUnavailable;
return html`
<div class="section-heading">
<div>
<h2>Pi packages</h2>
<p>Managing Pi packages on <strong>${targetLabel}</strong>. Install, remove, and update packages managed by Pi on the selected machine. Pi packages can provide extensions, skills, prompt templates, themes, context/system prompt files, and PI WEB browser plugins.</p>
</div>
<button class="secondary" title=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : `Reload Pi packages from ${targetLabel}`} ?disabled=${this.loading || this.isOperating || packageManagementUnavailable} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
<div class="trust-warning"><strong>Trusted code warning:</strong> Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.</div>
${this.renderCompatibilityNote(targetLabel)}
${this.renderMessages()}
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
<label for="package-source">Pi package source</label>
<div class="install-row">
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating || packageManagementUnavailable} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" title=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : "Install this Pi package"} ?disabled=${this.isOperating || packageManagementUnavailable}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
</div>
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
<small>Installs run on ${targetLabel} and use Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>. PI WEB does not ask you to choose an install location.</small>
</form>
<settings-panel-frame
heading="Pi packages"
.description=${packagesDescription(targetLabel)}
actionLabel="Reload"
actionTitle=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : `Reload Pi packages from ${targetLabel}`}
.actionDisabled=${this.loading || this.isOperating || packageManagementUnavailable}
.notices=${this.panelNotices(targetLabel, showPackageControls)}
.onAction=${this.onReload}
>
${this.renderPanelContent(packages, target, targetLabel)}
</settings-panel-frame>
`;
}
private panelNotices(targetLabel: string, showTrustedCodeWarning: boolean): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.packageManagementUnavailable) {
notices.push({ type: "availability", content: this.packageManagementUnavailableMessage(targetLabel) });
} else if (this.error !== "") {
notices.push({ type: "error", content: this.error });
}
if (this.operationMessage !== "") notices.push({ type: "success", content: this.operationMessage });
if (showTrustedCodeWarning) {
notices.push({
type: "security",
content: html`<strong>Trusted code warning:</strong> Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.`,
});
}
return notices;
}
private renderPanelContent(packages: PiPackageInfo[], target: PiPackageTargetContext, targetLabel: string): TemplateResult | null {
if (this.packageManagementUnavailable) return null;
if (this.packagesResponse === undefined) {
return html`<div class="loading-card">${this.loading ? `Loading Pi packages from ${targetLabel}` : `Pi package list unavailable for ${targetLabel}. Use Reload to try again.`}</div>`;
}
return html`
${this.renderInstallForm(targetLabel)}
${this.renderPackageList(packages, target)}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.operationMessage !== "") return html`<div class="message success-message">${this.operationMessage}</div>`;
return null;
}
private renderCompatibilityNote(targetLabel: string): TemplateResult | null {
if (!this.packageManagementUnavailable || this.error !== "") return null;
return html`<div class="message error-message">Pi package management is not advertised by ${targetLabel}. Update and restart Pi-Web on that machine, then refresh the app before managing Pi packages.</div>`;
private renderInstallForm(targetLabel: string): TemplateResult {
return html`
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
<label for="package-source">Pi package source</label>
<div class="install-row">
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" title="Install this Pi package" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
</div>
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
<small>Installs run on ${targetLabel} and use Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>. PI WEB does not ask you to choose an install location.</small>
</form>
`;
}
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
const targetLabel = piPackageTargetLabel(target);
const packageListUnavailable = this.error !== "" && packages.length === 0;
const packageManagementUnavailable = this.packageManagementUnavailable;
const updateAllReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : updateAllPiPackagesDisabledReason(packages);
const showUpdateAllReason = updateAllReason !== undefined && (packages.length > 0 || (!this.loading && !packageListUnavailable));
const updateAllTitle = packageListUnavailable ? `Pi package list unavailable for ${targetLabel}` : updateAllReason ?? "Update all user-scope Pi packages";
const showUpdateAllReason = updateAllReason !== undefined && packages.length > 0;
const updateAllTitle = updateAllReason ?? "Update all user-scope Pi packages";
return html`
<section class="package-section" aria-label="Configured Pi packages">
<div class="package-toolbar">
@@ -86,10 +110,6 @@ export class SettingsPackagesPanel extends LitElement {
private renderPackageListContent(packages: PiPackageInfo[], targetLabel: string): TemplateResult {
if (this.loading && packages.length === 0) return html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>`;
if (this.error !== "" && packages.length === 0) {
if (this.packageManagementUnavailable) return html`<div class="loading-card">Pi package management is unavailable for ${targetLabel} until Pi-Web on that machine advertises package-management support.</div>`;
return html`<div class="loading-card">Pi package list unavailable for ${targetLabel}. Use Reload to try again.</div>`;
}
if (packages.length === 0) return html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>`;
return html`
<div class="package-list">
@@ -178,10 +198,9 @@ export class SettingsPackagesPanel extends LitElement {
static override styles = css`
:host { display: block; }
.section-heading, .package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div, .package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
.package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; }
h3, p { margin: 0; }
h3 { font-size: 15px; line-height: 1.25; }
p, small { color: var(--pi-muted); line-height: 1.45; }
button, input { font: inherit; }
@@ -191,17 +210,11 @@ export class SettingsPackagesPanel extends LitElement {
label { font-weight: 700; }
.secondary { flex: 0 0 auto; }
.danger { border-color: color-mix(in srgb, var(--pi-danger) 55%, var(--pi-border)); color: var(--pi-danger); }
.message, .loading-card, .trust-warning, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .trust-warning, .install-card { margin-bottom: 12px; }
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
.error-message, .field-error { color: var(--pi-danger); }
.error-message { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.field-error { color: var(--pi-danger); font-size: 12px; }
.install-card { display: grid; gap: 8px; }
.install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.field-error { font-size: 12px; }
.package-section { display: block; }
.package-toolbar { margin-top: 16px; }
.loading-card, .action-note { color: var(--pi-muted); }
.action-note { margin-bottom: 10px; font-size: 12px; }
.package-list { display: grid; gap: 10px; }
@@ -212,11 +225,15 @@ export class SettingsPackagesPanel extends LitElement {
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.section-heading, .package-toolbar { display: grid; gap: 12px; }
.section-heading .secondary, .package-toolbar .secondary { justify-self: start; }
.package-toolbar { display: grid; gap: 12px; }
.package-toolbar .secondary { justify-self: start; }
.install-row, .package-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
.package-actions { justify-self: start; flex-wrap: wrap; }
.package-main strong, .package-main small { white-space: normal; }
}
`;
}
function packagesDescription(targetLabel: string): TemplateResult {
return html`Managing Pi packages on <strong>${targetLabel}</strong>. Install, remove, and update packages managed by Pi on the selected machine. Pi packages can provide extensions, skills, prompt templates, themes, context/system prompt files, and PI WEB browser plugins.`;
}
@@ -0,0 +1,147 @@
import { html, type TemplateResult } from "lit";
import { describe, expect, it } from "vitest";
import { SettingsPanelFrame, settingsNoticeTone, type SettingsNotice } from "./SettingsPanelFrame";
describe("settings-panel-frame", () => {
it("renders header, ordered notices, and settings content in the shared order", () => {
const frame = new SettingsPanelFrame();
frame.heading = "Pi packages";
frame.description = "Manage packages on Lab Mac.";
frame.actionLabel = "Reload";
frame.notices = [
{ type: "availability", title: "Unavailable", content: "Package management is unavailable." },
{ type: "success", content: "Saved package settings." },
{ type: "security", content: html`<strong>Trusted code warning:</strong> Install packages only from sources you trust.` },
];
const rendered = flattenTemplateContent(frame.render());
expectTextOrder(rendered, [
"Pi packages",
"Manage packages on Lab Mac.",
"Reload",
"Unavailable",
"Package management is unavailable.",
"Saved package settings.",
"Trusted code warning:",
]);
expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"'));
});
it("maps notice types to consistent default tones and roles", () => {
const notices: readonly SettingsNotice[] = [
{ type: "availability", content: "Configuration unavailable." },
{ type: "success", content: "Saved." },
{ type: "security", content: "Trusted code warning." },
{ type: "info", content: "Loading…" },
];
const frame = new SettingsPanelFrame();
frame.notices = notices;
const values = collectTemplateValues(frame.render());
expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]);
expect(values).toEqual(expect.arrayContaining(["notice error", "alert", "notice success", "status", "notice warning", "note", "notice info"]));
});
it("wires the default header action through the frame", () => {
const frame = new SettingsPanelFrame();
let reloads = 0;
frame.actionLabel = "Reload";
frame.actionTitle = "Reload settings";
frame.actionDisabled = true;
frame.onAction = () => { reloads += 1; };
const values = collectTemplateValues(frame.render());
const action = values.find(isActionHandler);
expect(values).toEqual(expect.arrayContaining(["Reload settings", true, "Reload"]));
if (action === undefined) throw new Error("Action handler was not rendered");
action();
expect(reloads).toBe(1);
});
});
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function isActionHandler(value: unknown): value is () => void {
return typeof value === "function";
}
@@ -0,0 +1,137 @@
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
export const SETTINGS_NOTICE_TONES = ["error", "success", "warning", "info"] as const;
export type SettingsNoticeTone = (typeof SETTINGS_NOTICE_TONES)[number];
export const SETTINGS_NOTICE_TYPES = ["availability", "error", "success", "security", "warning", "info"] as const;
export type SettingsNoticeType = (typeof SETTINGS_NOTICE_TYPES)[number];
export type SettingsNoticeRole = "alert" | "note" | "status";
export type SettingsNoticeContent = string | TemplateResult;
export interface SettingsNotice {
readonly type: SettingsNoticeType;
readonly content: SettingsNoticeContent;
readonly tone?: SettingsNoticeTone;
readonly title?: string;
readonly role?: SettingsNoticeRole;
}
const DEFAULT_NOTICE_TONE: Record<SettingsNoticeType, SettingsNoticeTone> = {
availability: "error",
error: "error",
success: "success",
security: "warning",
warning: "warning",
info: "info",
};
export function settingsNoticeTone(notice: SettingsNotice): SettingsNoticeTone {
return notice.tone ?? DEFAULT_NOTICE_TONE[notice.type];
}
@customElement("settings-panel-frame")
export class SettingsPanelFrame extends LitElement {
@property() heading = "";
@property({ attribute: false }) description: SettingsNoticeContent = "";
@property() actionLabel = "";
@property() actionTitle = "";
@property({ type: Boolean }) actionDisabled = false;
@property({ attribute: false }) notices: readonly SettingsNotice[] = [];
@property({ attribute: false }) onAction?: () => void | Promise<void>;
override render(): TemplateResult {
return html`
<section class="panel" aria-label=${this.heading || "Settings panel"}>
<header class="section-heading">
<div class="heading-copy">
${this.heading === "" ? nothing : html`<h2>${this.heading}</h2>`}
<div class="description"><slot name="description">${this.description}</slot></div>
</div>
<div class="heading-actions"><slot name="actions">${this.renderDefaultAction()}</slot></div>
</header>
${this.renderNoticeStack()}
<div class="content"><slot></slot></div>
</section>
`;
}
private renderDefaultAction(): TemplateResult | typeof nothing {
if (this.actionLabel === "") return nothing;
return html`
<button
class="secondary"
title=${this.actionTitle || this.actionLabel}
?disabled=${this.actionDisabled}
@click=${() => { void this.onAction?.(); }}
>${this.actionLabel}</button>
`;
}
private renderNoticeStack(): TemplateResult | typeof nothing {
if (this.notices.length === 0) return nothing;
return html`
<div class="notice-stack" aria-label="Settings notices">
${this.notices.map((notice) => this.renderNotice(notice))}
</div>
`;
}
private renderNotice(notice: SettingsNotice): TemplateResult {
const tone = settingsNoticeTone(notice);
const role = notice.role ?? defaultNoticeRole(tone);
const title = notice.title;
return html`
<article class=${`notice ${tone}`} role=${role}>
${title === undefined || title === "" ? nothing : html`<strong class="notice-title">${title}</strong>`}
<div class="notice-content">${notice.content}</div>
</article>
`;
}
static override styles = css`
:host { display: block; }
.panel { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.heading-copy { display: grid; gap: 6px; min-width: 0; }
.heading-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
h2 { margin: 0; font-size: 17px; line-height: 1.25; }
.description { color: var(--pi-muted); line-height: 1.45; }
.description ::slotted(*) { margin: 0; }
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; }
button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.notice-stack { display: grid; gap: 12px; margin-bottom: 14px; }
.notice { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; line-height: 1.45; }
.notice.error { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.notice.success { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.notice.warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); }
.notice.info { color: var(--pi-muted); }
.notice-title { display: block; margin-bottom: 4px; color: inherit; }
.notice-content { min-width: 0; }
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; }
.content { display: grid; gap: 14px; min-width: 0; }
.content ::slotted(*) { min-width: 0; }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.heading-actions { justify-self: start; }
}
`;
}
function defaultNoticeRole(tone: SettingsNoticeTone): SettingsNoticeRole {
switch (tone) {
case "error": return "alert";
case "success": return "status";
case "warning":
case "info": return "note";
}
}
declare global {
interface HTMLElementTagNameMap {
"settings-panel-frame": SettingsPanelFrame;
}
}
@@ -2,42 +2,67 @@ import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo } from "../../api";
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
describe("settings-plugins-panel copy", () => {
it("names the selected machine in plugin scope copy", () => {
describe("settings-plugins-panel layout", () => {
it("orders load and save notices before the trusted-code warning and plugin content", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ plugins: { "remote-enabled": { enabled: true } } });
panel.pluginsResponse = { plugins: [pluginInfo("remote-enabled", true)] };
panel.error = "Failed to load PI WEB plugin settings from Lab Mac: PI WEB plugins: timed out.";
panel.savedMessage = "Config saved.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"PI WEB plugins",
"Enable or disable discovered PI WEB browser plugins on ",
"Lab Mac (remote machine)",
"Failed to load PI WEB plugin settings from Lab Mac: PI WEB plugins: timed out.",
"Config saved. Reload the browser tab to apply plugin changes.",
"Trusted code warning:",
"Config key on Lab Mac (remote machine):",
"remote-enabled",
]);
});
it("does not show a false empty state when the plugin response is missing", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
const template = panel.render();
const strings = collectTemplateStrings(template).join("");
const values = collectTemplateValues(template);
const rendered = flattenTemplateContent(panel.render());
expect(strings).toContain("Enable or disable discovered PI WEB browser plugins on ");
expect(strings).toContain("Config key on ");
expect(strings).toContain("No PI WEB browser plugins discovered on ");
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(3);
expect(rendered).toContain("PI WEB plugin list unavailable for Lab Mac (remote machine). Use Reload to try again.");
expect(rendered).not.toContain("No PI WEB browser plugins discovered");
expect(rendered).not.toContain("Trusted code warning");
});
});
describe("settings-plugins-panel state", () => {
it("shows disabled remote plugins from the selected machine plugin list", () => {
it("shows the empty plugin state only after a plugin response has loaded", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ plugins: { "remote-disabled": { enabled: false } } });
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
panel.pluginsResponse = { plugins: [] };
const values = collectTemplateValues(panel.render());
const rendered = flattenTemplateContent(panel.render());
expect(values).toContain("remote-disabled");
expect(values).toContain("Config disabled");
expect(values).toContain("Disabled");
expect(rendered).toContain("No PI WEB browser plugins discovered on Lab Mac (remote machine).");
expect(rendered).not.toContain("PI WEB plugin list unavailable");
expect(rendered).not.toContain("Trusted code warning");
});
it("disables plugin toggles while selected-machine config is unavailable", () => {
it("keeps loaded plugins visible but disabled when selected-machine config is unavailable", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
expect(collectTemplateStrings(panel.render()).join("")).toContain("Configuration is unavailable. Reload to try again before changing plugin enablement.");
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Configuration is unavailable. Reload to try again before changing plugin enablement.",
"Trusted code warning:",
"remote-disabled",
]);
expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again before changing plugin enablement.")).toBe(1);
expect(templateValues(renderPluginTemplate(panel, pluginInfo("remote-disabled", false))).filter(isBoolean)).toEqual([false, true]);
});
});
@@ -52,41 +77,57 @@ function isPanelRenderPlugin(value: unknown): value is (this: SettingsPluginsPan
return typeof value === "function";
}
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return strings;
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function countOccurrences(content: string, needle: string): number {
return content.split(needle).length - 1;
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
@@ -103,6 +144,10 @@ function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
@@ -1,6 +1,8 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
@customElement("settings-plugins-panel")
export class SettingsPluginsPanel extends LitElement {
@@ -16,30 +18,55 @@ export class SettingsPluginsPanel extends LitElement {
override render(): TemplateResult {
const plugins = this.pluginsResponse?.plugins ?? [];
const hasPluginResponse = this.pluginsResponse !== undefined;
return html`
<div class="section-heading">
<div>
<h2>PI WEB plugins</h2>
<p>Enable or disable discovered PI WEB browser plugins on <strong>${this.targetLabel}</strong>. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="trust-warning"><strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.</div>
<div class="plugin-note">Config key on ${this.targetLabel}: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
${this.configResponse === undefined && !this.loading ? html`<div class="loading-card">Configuration is unavailable. Reload to try again before changing plugin enablement.</div>` : null}
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered on ${this.targetLabel}.</div>` : html`
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
`}
<settings-panel-frame
heading="PI WEB plugins"
.description=${pluginsDescription(this.targetLabel)}
actionLabel="Reload"
actionTitle=${`Reload PI WEB plugins from ${this.targetLabel}`}
.actionDisabled=${this.loading}
.notices=${this.panelNotices(plugins.length > 0)}
.onAction=${this.onReload}
>
${this.renderPanelContent(plugins, hasPluginResponse)}
</settings-panel-frame>
`;
}
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 panelNotices(showTrustedCodeWarning: boolean): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.error !== "") notices.push({ type: "error", content: this.error });
if (this.shouldShowConfigUnavailableNotice(showTrustedCodeWarning)) {
notices.push({ type: "availability", content: "Configuration is unavailable. Reload to try again before changing plugin enablement." });
}
if (this.savedMessage !== "") notices.push({ type: "success", content: `${this.savedMessage} Reload the browser tab to apply plugin changes.` });
if (showTrustedCodeWarning) {
notices.push({
type: "security",
content: html`<strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.`,
});
}
return notices;
}
private shouldShowConfigUnavailableNotice(hasLoadedPlugins: boolean): boolean {
return hasLoadedPlugins && this.configResponse === undefined && !this.loading && this.error === "";
}
private renderPanelContent(plugins: PiWebPluginInfo[], hasPluginResponse: boolean): TemplateResult {
if (!hasPluginResponse) {
return html`<div class="loading-card">${this.loading ? "Loading PI WEB plugins…" : `PI WEB plugin list unavailable for ${this.targetLabel}. Use Reload to try again.`}</div>`;
}
if (plugins.length === 0) {
return html`<div class="loading-card">No PI WEB browser plugins discovered on ${this.targetLabel}.</div>`;
}
return html`
<div class="plugin-note">Config key on ${this.targetLabel}: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
`;
}
private renderPlugin(plugin: PiWebPluginInfo): TemplateResult {
@@ -67,22 +94,10 @@ export class SettingsPluginsPanel extends LitElement {
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, .trust-warning, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .trust-warning { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
input { font: inherit; }
input:disabled { opacity: .55; cursor: not-allowed; }
.loading-card, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .plugin-note { color: var(--pi-muted); }
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
.plugin-note { margin-bottom: 14px; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.plugin-list { display: grid; gap: 10px; }
.plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
@@ -94,10 +109,12 @@ export class SettingsPluginsPanel extends LitElement {
.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; }
}
`;
}
function pluginsDescription(targetLabel: string): TemplateResult {
return html`Enable or disable discovered PI WEB browser plugins on <strong>${targetLabel}</strong>. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.`;
}
@@ -1,23 +1,113 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
describe("settings-sessiond-panel copy", () => {
it("names the selected machine in the scope and restart copy", () => {
describe("settings-sessiond-panel layout", () => {
it("names the selected machine in the scope and restart notice when config is available", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ spawnSessions: true, subsessions: false });
const template = panel.render();
const strings = templateStrings(template);
const values = templateValues(template);
const rendered = flattenTemplateContent(panel.render());
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(2);
expect(strings.join("")).toContain("These settings affect the long-lived session runtime on ");
expect(strings.join("")).toContain("Restart required on ");
expect(strings.join("")).toContain("run <code>pi-web restart</code> on that machine");
expectTextOrder(rendered, [
"Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
"Reload",
"Restart required on Lab Mac (remote machine)",
"run <code>pi-web restart</code> on that machine",
"Config file",
"Allow agents to start sessions",
]);
});
it("orders save/load notices before the restart notice and settings content", () => {
const panel = new SettingsSessiondPanel();
panel.configResponse = configResponse({ spawnSessions: false });
panel.error = "Failed to save session-daemon config.";
panel.savedMessage = "Session daemon settings saved.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Restart required on local (local gateway)",
"Config file",
]);
});
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.error = "Selected-machine settings are not available on Lab Mac.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Selected-machine settings are not available on Lab Mac.",
"Configuration is unavailable. Reload to try again.",
]);
expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again.")).toBe(1);
expect(rendered).not.toContain("Restart required on");
expect(rendered).not.toContain("Allow agents to start sessions");
expect(rendered).not.toContain("Effective after environment overrides");
});
});
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function countOccurrences(content: string, needle: string): number {
return content.split(needle).length - 1;
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
@@ -30,6 +120,24 @@ function templateValues(template: TemplateResult): readonly unknown[] {
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -1,6 +1,8 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
@customElement("settings-sessiond-panel")
@@ -24,68 +26,80 @@ export class SettingsSessiondPanel extends LitElement {
// Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
return html`
<div class="section-heading">
<div>
<h2>Session daemon</h2>
<p>These settings affect the long-lived session runtime on ${this.targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="restart-note" role="note">Restart required on ${this.targetLabel}: run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.</div>
${config === undefined ? html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config.path}</code>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSpawn}
?disabled=${this.loading || this.saving || spawnOverridden}
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
>
<span>Enable the <code>spawn_session</code> tool</span>
</label>
<small>When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default.</small>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start tracked subsessions</span>
<span class="beta-badge">beta</span>
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSubsessions}
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
>
<span>Enable the <code>spawn_subsession</code> tools</span>
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
</section>
`}
<settings-panel-frame
heading="Session daemon"
.description=${sessiondDescription(this.targetLabel)}
actionLabel="Reload"
.actionDisabled=${this.loading}
.notices=${this.panelNotices(config)}
.onAction=${this.onReload}
>
${config === undefined ? this.renderUnavailableConfigState() : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config.path}</code>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSpawn}
?disabled=${this.loading || this.saving || spawnOverridden}
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
>
<span>Enable the <code>spawn_session</code> tool</span>
</label>
<small>When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default.</small>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start tracked subsessions</span>
<span class="beta-badge">beta</span>
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSubsessions}
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
>
<span>Enable the <code>spawn_subsession</code> tools</span>
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
</section>
`}
</settings-panel-frame>
`;
}
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}</div>`;
return null;
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.error !== "") notices.push({ type: "error", content: this.error });
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
if (config !== undefined) {
notices.push({
type: "warning",
title: `Restart required on ${this.targetLabel}`,
content: html`run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
});
}
return notices;
}
private renderUnavailableConfigState(): TemplateResult {
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
}
private async toggleSpawnSessions(event: Event): Promise<void> {
@@ -100,26 +114,16 @@ export class SettingsSessiondPanel extends LitElement {
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; }
h3 { margin: 0; font-size: 13px; line-height: 1.3; }
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 { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card, .effective-card, .restart-note { 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, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card { color: var(--pi-muted); }
.restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card { display: grid; gap: 5px; }
.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; }
.field { display: grid; gap: 7px; margin-bottom: 14px; }
.field { display: grid; gap: 7px; }
.field small { color: var(--pi-muted); line-height: 1.45; }
.field-heading { display: flex; align-items: center; gap: 8px; }
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
@@ -134,9 +138,11 @@ export class SettingsSessiondPanel extends LitElement {
.muted { color: var(--pi-muted); }
@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 sessiondDescription(targetLabel: string): string {
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsShortcutsPanel } from "./SettingsShortcutsPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
describe("settings-shortcuts-panel layout", () => {
it("renders header, ordered notices, and shortcut settings through the shared frame", () => {
const panel = new SettingsShortcutsPanel();
panel.configResponse = configResponse({ shortcuts: {} });
panel.error = "Failed to load shortcut settings.";
panel.savedMessage = "Shortcut settings saved.";
const template = panel.render();
const rendered = flattenTemplateContent(template);
expect(rendered).toContain("<settings-panel-frame");
expect(frameNotices(template).map((notice) => notice.type)).toEqual(["error", "success"]);
expectTextOrder(rendered, [
"Keyboard shortcuts",
"Edit app shortcuts by action.",
"<code>mod+k</code>",
"Reload",
"Failed to load shortcut settings.",
"Shortcut settings saved.",
"Chat composer",
"Config file",
"No actions registered.",
]);
});
it("keeps the prompt-enter card before the loading shortcuts state", () => {
const panel = new SettingsShortcutsPanel();
panel.loading = true;
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, ["Keyboard shortcuts", "Chat composer", "Loading shortcuts…"]);
expect(rendered).not.toContain("Config file");
});
});
function frameNotices(template: TemplateResult): readonly SettingsNotice[] {
const notices = collectTemplateValues(template).find(isSettingsNoticeArray);
if (notices === undefined) throw new Error("Expected settings-panel-frame notices to be rendered");
return notices;
}
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isSettingsNoticeArray(value: unknown): value is readonly SettingsNotice[] {
return Array.isArray(value) && value.every(isSettingsNotice);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -4,6 +4,8 @@ import type { AppAction } from "../../actions";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
@@ -25,6 +27,10 @@ const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: stri
},
];
function renderShortcutsDescription(): TemplateResult {
return html`Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.`;
}
@customElement("settings-shortcuts-panel")
export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) actions: AppAction[] = [];
@@ -87,38 +93,40 @@ export class SettingsShortcutsPanel extends LitElement {
const groups = shortcutGroups(this.actions);
const shortcutResolutions = this.shortcutResolutions();
return html`
<div class="section-heading">
<div>
<h2>Keyboard shortcuts</h2>
<p>Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
${this.renderPromptEnterPreferenceCard()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${this.configResponse?.path ?? "Unknown"}</code>
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
</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, shortcutResolutions.get(action.id)))}
</div>
</section>
`)}
`}
<settings-panel-frame
heading="Keyboard shortcuts"
.description=${renderShortcutsDescription()}
actionLabel="Reload"
.actionDisabled=${this.loading}
.notices=${this.panelNotices()}
.onAction=${this.onReload}
>
${this.renderPromptEnterPreferenceCard()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${this.configResponse?.path ?? "Unknown"}</code>
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
</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, shortcutResolutions.get(action.id)))}
</div>
</section>
`)}
`}
</settings-panel-frame>
`;
}
private renderMessages(): TemplateResult | null {
private panelNotices(): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
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;
if (error !== "") notices.push({ type: "error", content: error });
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
return notices;
}
private renderPromptEnterPreferenceCard(): TemplateResult {
@@ -335,25 +343,18 @@ export class SettingsShortcutsPanel extends LitElement {
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, p { margin: 0; }
h3 { font-size: 13px; line-height: 1.3; }
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; }
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card, .prompt-enter-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, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .config-path-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card { display: grid; gap: 5px; }
.config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; }
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; }
.prompt-enter-copy { display: grid; gap: 5px; min-width: 0; }
.prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; }
.prompt-enter-options { display: grid; gap: 7px; }
@@ -363,7 +364,7 @@ export class SettingsShortcutsPanel extends LitElement {
.prompt-enter-option span { display: grid; gap: 2px; }
.prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; }
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; }
.shortcut-group { margin: 0 0 16px; }
.shortcut-group { margin: 0; }
.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) minmax(360px, 48%); gap: 14px; align-items: start; padding: 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
@@ -392,8 +393,6 @@ export class SettingsShortcutsPanel extends LitElement {
.recording-hint { color: var(--pi-accent); font-size: 12px; }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.prompt-enter-card { grid-template-columns: minmax(0, 1fr); }
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.shortcut-status, .shortcut-actions { justify-content: flex-start; }