feat: Plugin API Completeness — file mutations, prompt editor, and attachment APIs

- WorkspaceFiles: writeFile, deleteFile, moveFile with path safety
  - writeFile: text/binary, auto-create dirs, overwrite option
  - deleteFile: idempotent, uses lstat (removes symlinks not targets)
  - moveFile: unix mv semantics, overwrite defaults to false
  - All mutations auto-refreshFiles() in File Explorer
  - Symlink escape prevention via realpath(dirname) check

- PluginPromptEditor: insertText, getText, getSelection, onPaste, onKeyDown, focus
  - Uses CM6 EditorView.domEventHandlers() via Compartment (not raw DOM)
  - Handlers registered before mount are preserved and applied on mount
  - First-to-consume-wins ordering for multi-plugin scenarios
  - insertText replaces selection (not inserts after)

- PluginAttachments: insertFileReference, getAttachedFiles, removeFileReference
  - insertFileReference validates file exists before inserting @path
  - Does not auto-focus editor (unlike prompt.insertText)
  - @file regex requires file extension to avoid matching emails

- Server endpoints: PUT /file, DELETE /file, POST /file/move
  - All work for local and federated machines

- Tests: 31 unit tests, 9 integration tests, 5 client tests
- Docs: 3 new sections in plugins.md
This commit is contained in:
marcus
2026-06-14 15:03:23 +02:00
parent 227187c4ca
commit 27a3b2b5ed
22 changed files with 1314 additions and 32 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
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, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, 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, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+63
View File
@@ -147,6 +147,69 @@ describe("machine-scoped terminal command-run API", () => {
});
});
describe("workspace file write API", () => {
it("sends text content with Content-Type text/plain", async () => {
const fetchMock = stubJsonFetch({ path: "hello.txt", size: 11, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "hello.txt", "hello world");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
expect(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
});
it("sends binary content with Content-Type application/octet-stream", async () => {
const fetchMock = stubJsonFetch({ path: "image.png", size: 4, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "image.png", binary);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
expect(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
});
it("sends createDirs and overwrite query parameters", async () => {
const fetchMock = stubJsonFetch({ path: "config/new.json", size: 10, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "config/new.json", "{\"a\":1}", { createDirs: false, overwrite: false });
expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchCall(fetchMock, 0);
expect(url).toContain("createDirs=false");
expect(url).toContain("overwrite=false");
});
it("parses WriteWorkspaceFileResponse correctly", async () => {
const fetchMock = stubJsonFetch({ path: "output/result.txt", size: 42, modifiedAt: "2026-06-10T12:00:00.000Z", created: true });
const result = await workspacesApi.writeWorkspaceFile("p 1", "w/1", "output/result.txt", "content");
expect(fetchMock).toHaveBeenCalledOnce();
expect(result).toEqual({
path: "output/result.txt",
size: 42,
modifiedAt: "2026-06-10T12:00:00.000Z",
created: true,
});
});
it("routes through machine prefix for remote machines", async () => {
const fetchMock = stubJsonFetch({ path: "file.txt", size: 5, modifiedAt: "2026-06-10T00:00:00.000Z", created: false });
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "file.txt", "data", undefined, "remote a");
expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchCall(fetchMock, 0);
expect(url).toContain("/api/machines/remote%20a/");
});
});
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
+30 -1
View File
@@ -1,4 +1,4 @@
import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -9,6 +9,7 @@ import {
parseClosed,
parseCommandResult,
parseDeleted,
parseDeleteWorkspaceFileResponse,
parseDetached,
parseFileContentResponse,
parseFileSuggestion,
@@ -21,6 +22,7 @@ import {
parseMachinesResponse,
parseMessagePage,
parseModelSelectionResponse,
parseMoveWorkspaceFileResponse,
parseOAuthFlowState,
parsePiWebConfigResponse,
parsePiWebPluginsResponse,
@@ -37,6 +39,7 @@ import {
parseTerminalCommandRun,
parseTerminalInfo,
parseThinkingLevelsResponse,
parseWriteWorkspaceFileResponse,
parseWorkspace,
parseWorkspaceActivityResponse,
} from "./parsers";
@@ -118,6 +121,32 @@ export const workspacesApi = {
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
writeWorkspaceFile: (projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId = "local") => {
const params = new URLSearchParams({ path });
if (options?.createDirs === false) params.set("createDirs", "false");
if (options?.overwrite === false) params.set("overwrite", "false");
const isBinary = content instanceof Uint8Array;
const body: BodyInit = isBinary ? new Uint8Array(content) : new TextEncoder().encode(content);
return request(
`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`,
parseWriteWorkspaceFileResponse,
{ method: "PUT", body, headers: { "Content-Type": isBinary ? "application/octet-stream" : "text/plain" } },
);
},
deleteWorkspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local"): Promise<DeleteWorkspaceFileResponse> => {
const params = new URLSearchParams({ path });
return request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, parseDeleteWorkspaceFileResponse, { method: "DELETE" });
},
moveWorkspaceFile: (projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId = "local") => {
const params = new URLSearchParams({ fromPath, toPath });
if (options?.createDirs === false) params.set("createDirs", "false");
if (options?.overwrite === true) params.set("overwrite", "true");
return request(
`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/move?${params.toString()}`,
parseMoveWorkspaceFileResponse,
{ method: "POST" },
);
},
};
export const sessionsApi = {
+1 -1
View File
@@ -1,6 +1,6 @@
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
if (init?.body !== undefined) headers.set("content-type", "application/json");
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, { ...init, headers });
if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({}));
+29 -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, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -336,6 +336,34 @@ export function parseFileContentResponse(value: unknown): FileContentResponse {
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
}
export function parseWriteWorkspaceFileResponse(value: unknown): WriteWorkspaceFileResponse {
const record = requireRecord(value);
return {
path: requireString(record, "path"),
size: requireNumber(record, "size"),
modifiedAt: requireString(record, "modifiedAt"),
created: requireBoolean(record, "created"),
};
}
export function parseDeleteWorkspaceFileResponse(value: unknown): DeleteWorkspaceFileResponse {
const record = requireRecord(value);
return {
path: requireString(record, "path"),
existed: requireBoolean(record, "existed"),
};
}
export function parseMoveWorkspaceFileResponse(value: unknown): MoveWorkspaceFileResponse {
const record = requireRecord(value);
return {
fromPath: requireString(record, "fromPath"),
toPath: requireString(record, "toPath"),
size: requireNumber(record, "size"),
modifiedAt: requireString(record, "modifiedAt"),
};
}
function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined {
if (value === undefined) return undefined;
if (value !== "image") throw new Error("Invalid file media type");
+102 -1
View File
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import type { PiWebPluginRegistration, PluginMachine, PluginAttachments, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -1198,6 +1198,21 @@ export class PiWebApp extends LitElement {
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
return {
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
writeFile: async (path, content, options) => {
const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId);
void this.files.refreshFiles();
return result;
},
deleteFile: async (path) => {
const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId);
void this.files.refreshFiles();
return result;
},
moveFile: async (fromPath, toPath, options) => {
const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId);
void this.files.refreshFiles();
return result;
},
};
}
@@ -1369,9 +1384,95 @@ export class PiWebApp extends LitElement {
}
}
private createPromptEditor(): PluginPromptEditor {
return {
insertText: (text: string) => {
const editor = this.promptEditor?.view;
if (!editor) return;
if (!editor.hasFocus) editor.focus();
const sel = editor.state.selection.main;
editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: text } });
},
getText: () => {
return this.promptEditor?.view?.state.doc.toString() ?? "";
},
getSelection: () => {
const editor = this.promptEditor?.view;
if (!editor) return null;
const sel = editor.state.selection.main;
if (sel.empty) return null;
return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) };
},
onPaste: (handler) => {
if (!this.promptEditor) {
console.warn("[pi-web] prompt.onPaste() called but prompt editor is not available. Handler will not be registered.");
return () => undefined;
}
const id = this.promptEditor.addPluginHandler("paste", handler);
return () => { this.promptEditor?.removePluginHandler(id); };
},
onKeyDown: (handler) => {
if (!this.promptEditor) {
console.warn("[pi-web] prompt.onKeyDown() called but prompt editor is not available. Handler will not be registered.");
return () => undefined;
}
const id = this.promptEditor.addPluginHandler("keydown", handler);
return () => { this.promptEditor?.removePluginHandler(id); };
},
focus: () => {
this.promptEditor?.focusInput();
},
};
}
private createPluginAttachments(): PluginAttachments {
const workspace = this.state.selectedWorkspace;
const machineId = selectedMachineId(this.state);
return {
insertFileReference: async (path: string) => {
if (!workspace) throw new Error("No workspace selected");
try {
await workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId);
} catch {
throw new Error(`File not found in workspace: ${path}`);
}
const reference = `@${path}`;
const editor = this.promptEditor?.view;
if (editor) {
const sel = editor.state.selection.main;
editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: reference } });
}
return reference;
},
getAttachedFiles: () => {
const text = this.promptEditor?.view?.state.doc.toString() ?? "";
const matches: string[] = [];
const atFilePattern = /@([\w./\-\u00C0-\u024F]+(?:\.[\w]+))/g;
let m: RegExpExecArray | null;
while ((m = atFilePattern.exec(text)) !== null) {
if (m[1] !== undefined) matches.push(m[1]);
}
return matches;
},
removeFileReference: (path: string) => {
const editor = this.promptEditor?.view;
if (!editor) return;
const text = editor.state.doc.toString();
const reference = `@${path}`;
const index = text.indexOf(reference);
if (index === -1) return;
editor.dispatch({
changes: { from: index, to: index + reference.length, insert: "" },
});
},
};
}
private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
prompt: this.createPromptEditor(),
attachments: this.createPluginAttachments(),
piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
+61
View File
@@ -27,6 +27,9 @@ interface PendingAttachment {
size: number;
}
type PluginPasteHandler = (event: ClipboardEvent) => boolean;
type PluginKeydownHandler = (event: KeyboardEvent) => boolean;
@customElement("prompt-editor")
export class PromptEditor extends LitElement {
@property({ type: Boolean }) disabled = false;
@@ -56,6 +59,10 @@ export class PromptEditor extends LitElement {
private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment();
private readonly readOnlyCompartment = new Compartment();
private readonly pluginHandlersCompartment = new Compartment();
private nextHandlerId = 0;
private readonly pasteHandlers = new Map<number, PluginPasteHandler>();
private readonly keydownHandlers = new Map<number, PluginKeydownHandler>();
protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId") && !changed.has("machineId")) return;
@@ -79,6 +86,8 @@ export class PromptEditor extends LitElement {
}
override disconnectedCallback(): void {
this.pasteHandlers.clear();
this.keydownHandlers.clear();
this.editor?.destroy();
this.editor = undefined;
super.disconnectedCallback();
@@ -114,6 +123,57 @@ export class PromptEditor extends LitElement {
this.editor?.focus();
}
/** Get the underlying CM6 EditorView, or undefined if not yet mounted. */
get view(): EditorView | undefined {
return this.editor;
}
/** Register a plugin event handler. Returns a numeric ID for later removal. */
addPluginHandler(...args: ["paste", PluginPasteHandler] | ["keydown", PluginKeydownHandler]): number {
const [type, handler] = args;
const id = this.nextHandlerId++;
if (type === "paste") {
this.pasteHandlers.set(id, handler);
} else {
this.keydownHandlers.set(id, handler);
}
this.reconfigurePluginHandlers();
return id;
}
/** Remove a previously registered plugin event handler by ID. */
removePluginHandler(id: number): void {
this.pasteHandlers.delete(id);
this.keydownHandlers.delete(id);
this.reconfigurePluginHandlers();
}
private reconfigurePluginHandlers(): void {
const extension = this.buildPluginHandlersExtension();
this.editor?.dispatch({
effects: this.pluginHandlersCompartment.reconfigure(extension),
});
}
private buildPluginHandlersExtension() {
const pasteHandlers = [...this.pasteHandlers.values()];
const keydownHandlers = [...this.keydownHandlers.values()];
return EditorView.domEventHandlers({
paste(event) {
for (const handler of pasteHandlers) {
if (handler(event)) return true;
}
return false;
},
keydown(event) {
for (const handler of keydownHandlers) {
if (handler(event)) return true;
}
return false;
},
});
}
private renderCompactStatus() {
const status = this.status;
if (status === undefined) return null;
@@ -223,6 +283,7 @@ export class PromptEditor extends LitElement {
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
this.pluginHandlersCompartment.of(this.buildPluginHandlersExtension()),
EditorView.updateListener.of((update) => {
if (update.docChanged) this.updateDraft(update.state.doc.toString());
}),
+42 -4
View File
@@ -1,6 +1,6 @@
import { html } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api";
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
@@ -14,6 +14,19 @@ function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
const context: PluginRuntimeContext = {
state: { ...initialAppState(), ...statePatch },
prompt: {
insertText: vi.fn(),
getText: vi.fn(() => ""),
getSelection: vi.fn(() => null),
onPaste: vi.fn(() => vi.fn()),
onKeyDown: vi.fn(() => vi.fn()),
focus: vi.fn(() => { calls.push("prompt.focus"); }),
},
attachments: {
insertFileReference: vi.fn(),
getAttachedFiles: vi.fn(() => []),
removeFileReference: vi.fn(),
},
piWebUnstable: {
terminalCommandRuns: {
runCommand: vi.fn(),
@@ -335,7 +348,7 @@ describe("PluginRegistry", () => {
context.host.requestRender();
return [{ type: "text", text: context.machine.id }];
});
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } });
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
registry.register({
id: "example",
@@ -545,7 +558,7 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
}
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) };
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
@@ -562,7 +575,7 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn() },
files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() },
terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() },
fileTree: [],
@@ -613,6 +626,31 @@ function testStatus(patch: Partial<SessionStatus> = {}): SessionStatus {
};
}
function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse {
return {
path,
size: 0,
modifiedAt: "2026-05-20T00:00:00.000Z",
created: true,
};
}
function testDeleteFileResponse(path = "README.md"): DeleteWorkspaceFileResponse {
return {
path,
existed: true,
};
}
function testMoveFileResponse(fromPath = "old.txt", toPath = "new.txt"): MoveWorkspaceFileResponse {
return {
fromPath,
toPath,
size: 0,
modifiedAt: "2026-05-20T00:00:00.000Z",
};
}
function testMachine(id: string) {
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
}
+21 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { AppAction } from "../actions";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { AppState } from "../appState";
import type { SettingsSection } from "../settingsRoute";
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
@@ -50,6 +50,9 @@ export interface PluginMachine {
export interface WorkspaceFiles {
readFile(path: string): Promise<FileContentResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
}
export interface WorkspaceHost {
@@ -83,8 +86,25 @@ export interface TerminalCommandRunsInternalRuntime {
open(options?: { terminalId?: string | undefined }): void;
}
export interface PluginPromptEditor {
insertText(text: string): void;
getText(): string;
getSelection(): { start: number; end: number; text: string } | null;
onPaste(handler: (event: ClipboardEvent) => boolean): () => void;
onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void;
focus(): void;
}
export interface PluginAttachments {
insertFileReference(path: string): Promise<string>;
getAttachedFiles(): string[];
removeFileReference(path: string): void;
}
export interface PluginRuntimeContext {
state: AppState;
prompt: PluginPromptEditor;
attachments: PluginAttachments;
piWebUnstable?: PiWebUnstableRuntimeContext;
openActionPalette: () => void;
focusPrompt: () => void;