Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/desktop/electron/main/agent-host-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function createAgentHostBridge(options: AgentHostBridgeOptions) {
{
sessionId: request.sessionId,
content: request.content,
...(request.composerDisplay ? { composerDisplay: request.composerDisplay } : {}),
...(request.sessionMessageId ? { sessionMessageId: request.sessionMessageId } : {}),
...(request.attachments ? { attachments: request.attachments } : {}),
...(permissionModeOverride ? { permissionMode: permissionModeOverride } : {}),
Expand Down Expand Up @@ -158,6 +159,7 @@ export function createAgentHostBridge(options: AgentHostBridgeOptions) {
sessionId: request.sessionId,
expectedTurnId: request.turnId,
content: request.content,
...(request.composerDisplay ? { composerDisplay: request.composerDisplay } : {}),
...(request.sessionMessageId ? { messageId: request.sessionMessageId } : {}),
...(request.attachments ? { attachments: request.attachments } : {}),
},
Expand Down Expand Up @@ -259,6 +261,7 @@ export function createAgentHostBridge(options: AgentHostBridgeOptions) {
...(request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {}),
input: {
text: request.content,
...(request.composerDisplay ? { composerDisplay: request.composerDisplay } : {}),
...(request.sessionMessageId ? { sessionMessageId: request.sessionMessageId } : {}),
...(request.attachments ? { attachments: request.attachments } : {}),
},
Expand All @@ -272,6 +275,7 @@ export function createAgentHostBridge(options: AgentHostBridgeOptions) {
id: result.turn.id,
sessionId: request.sessionId,
content: request.content,
...(request.composerDisplay ? { composerDisplay: request.composerDisplay } : {}),
...(request.sessionMessageId ? { sessionMessageId: request.sessionMessageId } : {}),
...(request.attachments ? { attachments: request.attachments } : {}),
position: 0,
Expand Down Expand Up @@ -401,7 +405,8 @@ function toQueueSummary(entry: QueueEntryView): QueuedTurnSummary {
id: entry.turn.id,
sessionId: entry.turn.sessionId,
content: entry.content,
...(entry.sessionMessageId ? { sessionMessageId: entry.sessionMessageId } : {}),
...(entry.composerDisplay ? { composerDisplay: entry.composerDisplay } : {}),
...(entry.sessionMessageId ? { sessionMessageId: entry.sessionMessageId } : {}),
...(entry.attachments ? { attachments: entry.attachments } : {}),
position: entry.turn.queuePosition ?? 0,
...(entry.priority !== undefined ? { priority: entry.priority } : {}),
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/electron/main/ipc/agent-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseComposerPromptDisplay } from "@pi-desktop/shared";
import { IPC, ErrorCodes, compactionRecordId, isGlobalPermissionMode, isRpcTimeoutError, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, canonicalThinkingLevel, type ThinkingLevel } from "@pi-desktop/shared";
import type { FinishTurn } from "../runtime/plans";
import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime";
Expand Down Expand Up @@ -276,6 +277,7 @@ export function registerAgentIpc({
id: durableUserMessageId(req.messageId, session.session?.messages ?? []),
role: "user",
content: req.content,
composerDisplay: parseComposerPromptDisplay(req.composerDisplay),
status: "complete",
createdAt: new Date().toISOString(),
steering: true,
Expand All @@ -296,7 +298,7 @@ export function registerAgentIpc({
handle(IPC.invoke.agentPrompt, async (req: AgentPromptRequest) => {
if (!sidecar) throw new Error("sidecar unavailable");
if (req.sessionId.startsWith("native-pi:")) {
if (req.sessionMessageId || req.truncateFromMessageId || req.truncateBefore !== undefined || req.attachments?.length) {
if (req.composerDisplay || req.sessionMessageId || req.truncateFromMessageId || req.truncateBefore !== undefined || req.attachments?.length) {
throw Object.assign(new Error("Native Pi continuation currently supports text prompts only"), {
errorCode: ErrorCodes.INVALID_ARGUMENT,
});
Expand Down Expand Up @@ -524,6 +526,7 @@ export function registerAgentIpc({

role: "user" as const,
content: promptContent,
composerDisplay: parseComposerPromptDisplay(req.composerDisplay),
...(sessionMessage ? { sessionMessage: sessionMessage.origin } : {}),
createdAt: new Date().toISOString(),
status: "complete" as const,
Expand Down Expand Up @@ -731,7 +734,7 @@ export function registerAgentIpc({
handle(IPC.invoke.agentQueuePush, async (req: AgentQueuePushRequest) => {
rejectNativeAgentOperation(req.sessionId);
if (!agentHostBridge) throw new Error("agent host unavailable");
return agentHostBridge.queue.push(req);
return agentHostBridge.queue.push({ ...req, composerDisplay: parseComposerPromptDisplay(req.composerDisplay) });
});
handle(IPC.invoke.agentQueueList, async (req: { sessionId: string }) => {
rejectNativeAgentOperation(req.sessionId);
Expand Down
30 changes: 29 additions & 1 deletion apps/desktop/electron/main/ipc/plugin-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { readFileSync, realpathSync, statSync } from "node:fs";
import { relative, isAbsolute, sep } from "node:path";
import { dialog } from "electron";
import { IPC, type ActivationScope } from "@pi-desktop/shared";
import { IPC, isActiveInProject, type ActivationScope } from "@pi-desktop/shared";
import { isTemplateName, scaffold } from "@pi-desktop/plugin-devkit";
import type { AgentExtensionBridge } from "../agent-extensions";
import type { BrowserHost } from "../browser-host";
Expand All @@ -8,6 +10,7 @@ import { BROWSER_PLUGIN_ID } from "../browser-host";
import type { Logger } from "../logger";
import {
readDevPluginDeclaration,
resolveInsidePlugin,
widenedFsScope,
type PluginRuntime,
} from "../plugin-runtime";
Expand All @@ -22,6 +25,7 @@ export type PluginIpcDependencies = {
browserHost: BrowserHost;
pluginViews: PluginViewHost;
pluginScopes: Map<string, ActivationScope>;
currentWorkspacePath: () => string | null;
rememberPluginScopes: (list: any[]) => void;
sendToRenderer: (channel: string, payload?: unknown) => void;
logger: Pick<Logger, "app">;
Expand All @@ -36,6 +40,7 @@ export function registerPluginIpc({
browserHost,
pluginViews,
pluginScopes,
currentWorkspacePath,
rememberPluginScopes,
sendToRenderer,
logger,
Expand Down Expand Up @@ -112,6 +117,29 @@ export function registerPluginIpc({
widened: widenedFsScope(approval.fs, declared.fs),
};
};
handle(IPC.invoke.pluginRendererEntries, async () => {
if (!host) throw new Error("host unavailable");
const result = await host.call<{ plugins: Array<{ id: string; enabled: boolean; scope?: ActivationScope }> }>("plugins.list");
const entries: Array<{ pluginId: string; source: string }> = [];
for (const plugin of result.plugins) {
const loaded = plugins.getLoaded(plugin.id);
if (!isActiveInProject(plugin, currentWorkspacePath()) || !loaded?.manifest.renderer || !loaded.permissions.has("ui.renderer")) continue;
const entry = resolveInsidePlugin(loaded.path, loaded.manifest.renderer);
if (!entry) continue;
try {
const root = realpathSync(loaded.path);
const file = realpathSync(entry);
const path = relative(root, file);
if (!path || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) continue;
if (!statSync(file).isFile() || statSync(file).size > 1024 * 1024) continue;
entries.push({ pluginId: plugin.id, source: readFileSync(file, "utf8") });
} catch (error) {
logger.app("plugin", "warn", "Renderer entry could not be read", { data: String(error) });
}
}
return { entries };
});

handle(IPC.invoke.pluginList, async () => {
if (!host) throw new Error("host unavailable");
const result = await host.call<{ plugins: any[] }>("plugins.list");
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
browserHost,
pluginViews,
pluginScopes,
currentWorkspacePath,
rememberPluginScopes,
sendToRenderer,
logger,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,11 +1049,13 @@ function readDeclaredAccess(pluginPath: string): {
}
const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as {
permissions?: unknown;
renderer?: unknown;
fs?: unknown;
};
const declared = Array.isArray(raw.permissions)
? raw.permissions.filter((entry): entry is string => typeof entry === "string")
: [];
if (typeof raw.renderer === "string" && raw.renderer) declared.push("ui.renderer");
const access = resolveFsAccess({ permissions: declared, fs: raw.fs });
return { permissions: access.permissions, fs: access.policy };
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/main/remote/remote-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function createRemoteBackend(options: RemoteBackendOptions): RemoteBacken
req: AgentPromptRequest | AgentQueuePushRequest,
admission: "reject_if_busy" | "queue",
): Promise<{ accepted: boolean; turn: RacpTurn }> => {
if (req.composerDisplay) throw capabilityUnavailable("this remote host does not accept composer references");
if ("attachments" in req && req.attachments?.length) {
throw capabilityUnavailable("this remote host does not accept attachments");
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: plugin-asset:; font-src 'self' data: plugin-asset:; media-src 'self' data: blob: https:; connect-src 'self' ws://localhost:* http://localhost:* ws://127.0.0.1:* http://127.0.0.1:*;"
content="default-src 'self'; script-src 'self' blob: 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: plugin-asset:; font-src 'self' data: plugin-asset:; media-src 'self' data: blob: https:; connect-src 'self' ws://localhost:* http://localhost:* ws://127.0.0.1:* http://127.0.0.1:*;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PI-Desktop</title>
Expand Down
33 changes: 3 additions & 30 deletions apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useComposerPluginReferences } from "../features/chat/composer/hooks/useComposerPluginReferences";
import {
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -50,10 +51,7 @@ import {
type ComposerPrefill,
} from "../features/chat/composer/model";
import {
createFileReference,
editorSelectionRange,
isImageFilePath,
nextChipToken,
} from "../features/chat/composer/editor";
import { useComposerAttachments } from "../features/chat/composer/hooks/useComposerAttachments";
import { useComposerDraft } from "../features/chat/composer/hooks/useComposerDraft";
Expand Down Expand Up @@ -445,6 +443,7 @@ export function Composer({
submit,
} = submitController;

const insertCompletion = useComposerPluginReferences(draft, inputBlocked, invalidatePromptEnhancement);
const composerAc = useComposerAutocomplete({
value,
cursor,
Expand All @@ -456,33 +455,7 @@ export function Composer({
const result = composerAc.accept(index);
if (!result) return;
invalidatePromptEnhancement();
// File accept strips the @ token (empty insert) and used to store a
// token-less chip above the textarea. Inline chips only paint when a
// sentinel is in the draft, so Enter looked like the reference vanished.
const acceptedFileReference = result.fileReference;
if (!acceptedFileReference) {
applyEditorDraft(result.value, fileReferencesRef.current, result.cursor);
return;
}
const token = nextChipToken();
const nextText =
result.value.slice(0, result.cursor) + token + result.value.slice(result.cursor);
applyEditorDraft(
nextText,
[
...fileReferencesRef.current,
createFileReference(
acceptedFileReference.path,
acceptedFileReference.name,
referenceSessionId,
{
kind: isImageFilePath(acceptedFileReference.path) ? "image" : "file",
token,
},
),
],
result.cursor + token.length,
);
insertCompletion(result);
};

// Keep the transcript's bottom reserve in sync with the composer's real
Expand Down
23 changes: 19 additions & 4 deletions apps/desktop/src/components/ComposerAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { ComposerCommand } from "@pi-desktop/shared";
import type { AutocompleteItem, useComposerAutocomplete } from "../hooks/use-composer-autocomplete";
import {
IconBookOpen,
IconChevronDown,
IconChevronRight,
IconFileText,
IconFolder,
IconPlug,
Expand Down Expand Up @@ -82,9 +84,9 @@ export function ComposerAutocomplete({
const rowClass = `composer-plus-item composer-ac-item ${active ? "kb-active" : ""}`;
const commonProps = {
key:
item.kind === "command"
item.kind === "file-group" ? "g:files" : item.kind === "command"
? `c:${item.command.kind}:${item.command.name}`
: `p:${item.entry.path}`,
: item.kind === "reference" ? `r:${item.reference.pluginId}:${item.reference.providerId}:${item.reference.refId}` : `p:${item.entry.path}`,
type: "button" as const,
role: "option" as const,
"aria-selected": active,
Expand All @@ -97,6 +99,12 @@ export function ComposerAutocomplete({
},
onMouseMove: () => ac.setHighlight(index),
};
if (item.kind === "file-group") {
return <button {...commonProps} aria-expanded={item.expanded}>
<span className="composer-ac-icon">{item.expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}</span>
<span className="composer-ac-name">{t("chat.fileGroup")} ({item.count})</span>
</button>;
}
if (item.kind === "command") {
return (
<button {...commonProps}>
Expand All @@ -118,6 +126,13 @@ export function ComposerAutocomplete({
</button>
);
}
if (item.kind === "reference") {
return <button {...commonProps} title={item.description}>
<span className="composer-ac-icon"><IconPlug size={14} /></span>
<span className="composer-ac-name"><Highlighted text={item.label} ranges={item.match.ranges} /></span>
{item.description ? <span className="composer-ac-desc">{item.description}</span> : null}
</button>;
}
const isDir = item.entry.kind === "dir";
const name = item.entry.path.split("/").pop() ?? item.entry.path;
const displayName = `${name}${isDir ? "/" : ""}`;
Expand Down Expand Up @@ -152,7 +167,7 @@ export function ComposerAutocomplete({
rows.push(renderRow(item, index));
});

const emptyKey =
const emptyKey = ac.pluginOnly ? "chat.referenceEmpty" :
ac.mode === "file"
? ac.noWorkspace
? "chat.fileNoWorkspace"
Expand All @@ -166,7 +181,7 @@ export function ComposerAutocomplete({
onClose={ac.close}
anchorRef={anchorRef}
menuClassName="composer-autocomplete"
label={t(ac.mode === "file" ? "chat.fileMenu" : "chat.slashMenu")}
label={t(ac.pluginOnly ? "chat.referenceMenu" : ac.mode === "file" ? "chat.fileMenu" : "chat.slashMenu")}
role="listbox"
side="top"
matchAnchorWidth
Expand Down
29 changes: 9 additions & 20 deletions apps/desktop/src/features/chat/composer/editor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { isChipTokenChar } from "../../../lib/composer-chip-token";
export { isChipTokenChar, nextChipToken } from "../../../lib/composer-chip-token";
import type { ComposerPluginReference as PluginReference } from "@pi-desktop/plugin-sdk";
import {
fileReferenceLabel,
formatFileInsert,
Expand Down Expand Up @@ -38,7 +41,8 @@ export function createFileReference(
preferredName?: string,
sessionId = "",
metadata?: {
kind?: "image" | "file";
kind?: "image" | "file" | "reference";
pluginReference?: PluginReference;
mimeType?: string;
token?: string;
},
Expand All @@ -48,29 +52,14 @@ export function createFileReference(
id: `composer-file-${composerFileReferenceSequence}`,
sessionId,
path,
name: fileReferenceLabel(path, preferredName),
name: metadata?.pluginReference?.label ?? fileReferenceLabel(path, preferredName),
kind: metadata?.kind ?? (isImageFilePath(path) ? "image" : "file"),
...(metadata?.pluginReference ? { pluginReference: metadata.pluginReference } : {}),
...(metadata?.mimeType ? { mimeType: metadata.mimeType } : {}),
...(metadata?.token ? { token: metadata.token } : {}),
};
}

const CHIP_TOKEN_BASE = 0xe000;
const CHIP_TOKEN_END = 0xf8ff;
let chipTokenSequence = 0;

export function nextChipToken(): string {
const range = CHIP_TOKEN_END - CHIP_TOKEN_BASE + 1;
chipTokenSequence = (chipTokenSequence + 1) % range;
return String.fromCodePoint(CHIP_TOKEN_BASE + chipTokenSequence);
}

export function isChipTokenChar(char: string): boolean {
if (char.length !== 1) return false;
const code = char.codePointAt(0) ?? 0;
return code >= CHIP_TOKEN_BASE && code <= CHIP_TOKEN_END;
}

function isChipElement(node: Node): boolean {
return (
node.nodeType === Node.ELEMENT_NODE &&
Expand Down Expand Up @@ -256,7 +245,7 @@ function chipSvg(key: string, size = 13): string {
}

export function isEditableTextReference(reference: ComposerFileReference): boolean {
return reference.mimeType?.toLowerCase() === "text/plain" || /\.txt$/i.test(reference.name);
return reference.kind !== "reference" && (reference.mimeType?.toLowerCase() === "text/plain" || /\.txt$/i.test(reference.name));
}

export function isComposerAudioReference(reference: ComposerFileReference): boolean {
Expand All @@ -276,7 +265,7 @@ function buildChipElement(
chip.className = "composer-chip";
chip.contentEditable = "false";
chip.dataset.token = token;
chip.title = reference.path;
chip.title = reference.pluginReference?.description ?? reference.path;
const editableText = isEditableTextReference(reference);
const activate = editableText ? () => onExpandText(token) : undefined;
chip.setAttribute("role", activate ? "button" : "listitem");
Expand Down
Loading
Loading