@pstdio/pocketcoder-remote 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.ts DELETED
@@ -1,149 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import type { ControlPlaneClient, WorkspaceSummary } from "./control-plane";
3
- import { WorkspaceTerminalError } from "./control-plane";
4
- import { relayTarget, type TargetRef } from "./session-target";
5
-
6
- export interface CommandUi {
7
- select(title: string, options: string[]): Promise<string | undefined>;
8
- confirm(title: string, message: string): Promise<boolean>;
9
- input(title: string, placeholder?: string): Promise<string | undefined>;
10
- notify(message: string, type?: "info" | "warning" | "error"): void;
11
- setWorkingMessage(message?: string): void;
12
- }
13
-
14
- export interface CommandContext {
15
- hasUI: boolean;
16
- ui: CommandUi;
17
- newSession(): Promise<{ cancelled: boolean }>;
18
- }
19
-
20
- export interface CommandRegistrar {
21
- registerCommand(
22
- name: string,
23
- options: {
24
- description?: string;
25
- handler: (args: string, ctx: CommandContext) => Promise<void>;
26
- },
27
- ): void;
28
- }
29
-
30
- export interface WorkspaceCommandDeps {
31
- targets: TargetRef;
32
- controlPlane: ControlPlaneClient;
33
- waitTimeoutMs?: number;
34
- }
35
-
36
- function workspaceLabel(workspace: WorkspaceSummary): string {
37
- return `${workspace.external_id} · ${workspace.template.name} · ${workspace.id.slice(0, 8)}`;
38
- }
39
-
40
- async function switchToWorkspace(
41
- deps: WorkspaceCommandDeps,
42
- ctx: CommandContext,
43
- workspaceId: string,
44
- ): Promise<void> {
45
- const target = deps.targets.current;
46
- if (target.mode === "direct") return;
47
- deps.targets.set(relayTarget(target.baseUrl, target.key, workspaceId));
48
- await ctx.newSession();
49
- }
50
-
51
- function requireRelayCapable(deps: WorkspaceCommandDeps, ctx: CommandContext): boolean {
52
- if (!ctx.hasUI) return false;
53
- if (deps.targets.current.mode === "direct") {
54
- ctx.ui.notify(
55
- "workspace commands need a PocketCoder server (POCKETCODER_URL), not a direct AgentAPI URL",
56
- "warning",
57
- );
58
- return false;
59
- }
60
- return true;
61
- }
62
-
63
- async function pickWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
64
- const workspaces = (await deps.controlPlane.workspaces.list({ state: "ready" })).items;
65
- if (workspaces.length === 0) {
66
- ctx.ui.notify("no ready workspaces; use /workspace-create", "info");
67
- return;
68
- }
69
- const labels = workspaces.map(workspaceLabel);
70
- const selection = await ctx.ui.select("Switch workspace", labels);
71
- if (selection === undefined) return;
72
- const workspace = workspaces[labels.indexOf(selection)];
73
- if (workspace) await switchToWorkspace(deps, ctx, workspace.id);
74
- }
75
-
76
- async function createWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
77
- const templates = await deps.controlPlane.templates.list();
78
- if (templates.length === 0) {
79
- ctx.ui.notify("no templates available to this key", "warning");
80
- return;
81
- }
82
- const labels = templates.map((template) => `${template.name}@${template.version}`);
83
- const selection = await ctx.ui.select("Create workspace from template", labels);
84
- if (selection === undefined) return;
85
- const template = templates[labels.indexOf(selection)];
86
- if (!template) return;
87
- const externalId =
88
- (await ctx.ui.input("External id", `pi-${randomUUID()}`))?.trim() || `pi-${randomUUID()}`;
89
-
90
- try {
91
- ctx.ui.setWorkingMessage(`creating workspace ${externalId}`);
92
- const created = await deps.controlPlane.workspaces.create({
93
- externalId,
94
- templateName: template.name,
95
- });
96
- const ready = await deps.controlPlane.workspaces.waitForReady(
97
- created,
98
- deps.waitTimeoutMs ?? 300_000,
99
- {
100
- onTick: (workspace) => ctx.ui.setWorkingMessage(`workspace ${workspace.state}`),
101
- },
102
- );
103
- await switchToWorkspace(deps, ctx, ready.id);
104
- } catch (error) {
105
- const message =
106
- error instanceof WorkspaceTerminalError
107
- ? error.message
108
- : `workspace create failed: ${String(error)}`;
109
- ctx.ui.notify(message, "error");
110
- } finally {
111
- ctx.ui.setWorkingMessage();
112
- }
113
- }
114
-
115
- async function cancelWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
116
- const target = deps.targets.current;
117
- if (target.mode !== "relay") {
118
- ctx.ui.notify("no workspace attached", "info");
119
- return;
120
- }
121
- const confirmed = await ctx.ui.confirm(
122
- "Cancel workspace",
123
- `Cancel workspace ${target.workspaceId}? The remote agent stops and the workspace is discarded.`,
124
- );
125
- if (!confirmed) return;
126
- await deps.controlPlane.workspaces.cancel(target.workspaceId);
127
- ctx.ui.notify(`workspace ${target.workspaceId.slice(0, 8)} canceled`, "info");
128
- }
129
-
130
- export function registerWorkspaceCommands(pi: CommandRegistrar, deps: WorkspaceCommandDeps): void {
131
- pi.registerCommand("workspace", {
132
- description: "Switch to another ready PocketCoder workspace",
133
- handler: async (_args, ctx) => {
134
- if (requireRelayCapable(deps, ctx)) await pickWorkspace(deps, ctx);
135
- },
136
- });
137
- pi.registerCommand("workspace-create", {
138
- description: "Create a PocketCoder workspace from a template and switch to it",
139
- handler: async (_args, ctx) => {
140
- if (requireRelayCapable(deps, ctx)) await createWorkspace(deps, ctx);
141
- },
142
- });
143
- pi.registerCommand("workspace-cancel", {
144
- description: "Cancel the current PocketCoder workspace",
145
- handler: async (_args, ctx) => {
146
- if (requireRelayCapable(deps, ctx)) await cancelWorkspace(deps, ctx);
147
- },
148
- });
149
- }
@@ -1,30 +0,0 @@
1
- import {
2
- ConversationGoneError,
3
- type ConversationMessage,
4
- PocketCoderClient,
5
- PocketCoderError,
6
- TERMINAL_WORKSPACE_STATES,
7
- type WorkspaceSummary,
8
- WorkspaceTerminalError,
9
- } from "@pstdio/pocketcoder-client";
10
-
11
- export {
12
- ConversationGoneError,
13
- type ConversationMessage,
14
- PocketCoderError as ControlPlaneError,
15
- TERMINAL_WORKSPACE_STATES,
16
- type WorkspaceSummary,
17
- WorkspaceTerminalError,
18
- };
19
-
20
- export interface ControlPlaneConfig {
21
- baseUrl: string;
22
- key: string;
23
- }
24
-
25
- /** @internal The remote UI keeps its historical config vocabulary at its boundary. */
26
- export class ControlPlaneClient extends PocketCoderClient {
27
- constructor(config: ControlPlaneConfig, fetchImpl: typeof fetch = fetch) {
28
- super({ baseUrl: config.baseUrl, apiKey: config.key }, fetchImpl);
29
- }
30
- }
@@ -1,19 +0,0 @@
1
- export function serviceUrlFromEnvironment(env: NodeJS.ProcessEnv = process.env): {
2
- serviceUrl: string;
3
- key: string;
4
- } {
5
- const directUrl = env.POCKETCODER_AGENTAPI_URL;
6
- const key = env.POCKETCODER_KEY;
7
- if (!key) throw new Error("POCKETCODER_KEY is required");
8
- if (directUrl) return { serviceUrl: directUrl, key };
9
-
10
- const baseUrl = (env.POCKETCODER_URL ?? "http://127.0.0.1:7080").replace(/\/$/, "");
11
- const workspaceId = env.POCKETCODER_WORKSPACE_ID;
12
- if (!workspaceId) {
13
- throw new Error("POCKETCODER_WORKSPACE_ID or POCKETCODER_AGENTAPI_URL is required");
14
- }
15
- return {
16
- serviceUrl: `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent`,
17
- key,
18
- };
19
- }
package/src/extension.ts DELETED
@@ -1,200 +0,0 @@
1
- import {
2
- type Api,
3
- type AssistantMessage,
4
- type AssistantMessageEventStream,
5
- type Context,
6
- createAssistantMessageEventStream,
7
- type Model,
8
- } from "@earendil-works/pi-ai";
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import {
11
- collectTurnFiles,
12
- DIRECT_MODE_ATTACHMENT_ERROR,
13
- registerAttachCommand,
14
- uploadTurnFiles,
15
- userTextOf,
16
- } from "./attachments";
17
- import { RemoteAgentClient } from "./client";
18
- import { registerWorkspaceCommands } from "./commands";
19
- import { ControlPlaneClient } from "./control-plane";
20
- import { replayHistory } from "./history";
21
- import { registerConversationRenderers } from "./renderers";
22
- import { emitRemoteResponse } from "./response-stream";
23
- import { relayTarget, TargetRef, targetFromEnvironment } from "./session-target";
24
- import { STATUS_KEY, StatusPoller } from "./status";
25
-
26
- const PROVIDER = "pocketcoder-agentapi";
27
- const MODEL = "remote-agent";
28
-
29
- function emptyUsage(): AssistantMessage["usage"] {
30
- return {
31
- input: 0,
32
- output: 0,
33
- cacheRead: 0,
34
- cacheWrite: 0,
35
- totalTokens: 0,
36
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
37
- };
38
- }
39
-
40
- function remoteStream(
41
- targets: TargetRef,
42
- controlPlane: ControlPlaneClient | undefined,
43
- attachmentQueue: string[],
44
- model: Model<Api>,
45
- context: Context,
46
- signal?: AbortSignal,
47
- onOutputStart?: () => void,
48
- ): AssistantMessageEventStream {
49
- const stream = createAssistantMessageEventStream();
50
- const output: AssistantMessage = {
51
- role: "assistant",
52
- content: [],
53
- api: model.api,
54
- provider: model.provider,
55
- model: model.id,
56
- usage: emptyUsage(),
57
- stopReason: "pending",
58
- timestamp: Date.now(),
59
- };
60
-
61
- void (async () => {
62
- stream.push({ type: "start", partial: output });
63
- try {
64
- const target = targets.current;
65
- if (target.mode === "unset") {
66
- throw new Error("no workspace attached; run /workspace or /workspace-create first");
67
- }
68
- const files = collectTurnFiles(context, attachmentQueue);
69
- if (files.length > 0 && (target.mode !== "relay" || !controlPlane)) {
70
- throw new Error(DIRECT_MODE_ATTACHMENT_ERROR);
71
- }
72
- let attachmentIds: string[] = [];
73
- if (files.length > 0 && target.mode === "relay" && controlPlane) {
74
- attachmentIds = await uploadTurnFiles(controlPlane, target.workspaceId, files);
75
- attachmentQueue.length = 0;
76
- }
77
- const client = new RemoteAgentClient({ serviceUrl: target.serviceUrl, key: target.key });
78
- await emitRemoteResponse(
79
- stream,
80
- output,
81
- async (onSnapshot) =>
82
- await client.send(userTextOf(context), signal, attachmentIds, onSnapshot),
83
- onOutputStart,
84
- );
85
- } catch (error) {
86
- output.stopReason = signal?.aborted ? "aborted" : "error";
87
- output.errorMessage = error instanceof Error ? error.message : String(error);
88
- stream.push({ type: "error", reason: output.stopReason, error: output });
89
- } finally {
90
- stream.end();
91
- }
92
- })();
93
-
94
- return stream;
95
- }
96
-
97
- async function pickInitialWorkspace(
98
- controlPlane: ControlPlaneClient,
99
- targets: TargetRef,
100
- context: ExtensionContext,
101
- ): Promise<void> {
102
- const target = targets.current;
103
- if (target.mode !== "unset" || !context.hasUI) return;
104
- const workspaces = (await controlPlane.workspaces.list({ state: "ready" })).items;
105
- if (workspaces.length === 0) {
106
- context.ui.notify("no ready workspaces; run /workspace-create", "warning");
107
- return;
108
- }
109
- const labels = workspaces.map(
110
- (workspace) =>
111
- `${workspace.external_id} · ${workspace.template.name} · ${workspace.id.slice(0, 8)}`,
112
- );
113
- const selection = await context.ui.select("Attach to workspace", labels);
114
- if (selection === undefined) {
115
- context.ui.notify("no workspace attached; run /workspace to attach", "warning");
116
- return;
117
- }
118
- const workspace = workspaces[labels.indexOf(selection)];
119
- if (workspace) targets.set(relayTarget(target.baseUrl, target.key, workspace.id));
120
- }
121
-
122
- export default function (pi: ExtensionAPI): void {
123
- const targets = new TargetRef(targetFromEnvironment());
124
- const initial = targets.current;
125
- const controlPlane =
126
- initial.mode === "direct"
127
- ? undefined
128
- : new ControlPlaneClient({ baseUrl: initial.baseUrl, key: initial.key });
129
- const attachmentQueue: string[] = [];
130
- let poller: StatusPoller | undefined;
131
- let activeContext: ExtensionContext | undefined;
132
-
133
- pi.registerProvider(PROVIDER, {
134
- name: "PocketCoder remote agent",
135
- baseUrl: initial.mode === "direct" ? initial.serviceUrl : initial.baseUrl,
136
- apiKey: "local-ui",
137
- api: "openai-completions",
138
- models: [
139
- {
140
- id: MODEL,
141
- name: "PocketCoder remote agent",
142
- reasoning: false,
143
- input: ["text", "image"],
144
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
145
- contextWindow: 1_000_000,
146
- maxTokens: 100_000,
147
- },
148
- ],
149
- streamSimple: (model, context, options) =>
150
- remoteStream(targets, controlPlane, attachmentQueue, model, context, options?.signal, () =>
151
- activeContext?.ui.setWorkingVisible(false),
152
- ),
153
- });
154
-
155
- registerConversationRenderers(pi);
156
- registerAttachCommand(pi, { targets, queue: attachmentQueue });
157
- if (controlPlane) registerWorkspaceCommands(pi, { targets, controlPlane });
158
-
159
- pi.on("session_start", async (_event, context) => {
160
- activeContext = context;
161
- pi.setActiveTools([]);
162
- await poller?.stop();
163
- poller = undefined;
164
-
165
- if (controlPlane) await pickInitialWorkspace(controlPlane, targets, context);
166
- const target = targets.current;
167
- if (target.mode === "direct") {
168
- context.ui.setStatus(STATUS_KEY, "direct agentapi");
169
- return;
170
- }
171
- if (target.mode === "unset") {
172
- context.ui.setStatus(STATUS_KEY, "no workspace");
173
- return;
174
- }
175
- context.ui.setStatus(STATUS_KEY, `ws ${target.workspaceId.slice(0, 8)}`);
176
- if (!controlPlane) return;
177
- try {
178
- const workspace = await controlPlane.workspaces.get(target.workspaceId);
179
- await replayHistory(pi, controlPlane, target.workspaceId);
180
- poller = new StatusPoller(controlPlane, workspace, context.ui);
181
- poller.start();
182
- } catch (error) {
183
- context.ui.notify(
184
- `could not load workspace history: ${error instanceof Error ? error.message : String(error)}`,
185
- "warning",
186
- );
187
- }
188
- });
189
-
190
- pi.on("turn_start", async () => poller?.pause());
191
- pi.on("turn_end", async (_event, context) => {
192
- context.ui.setWorkingVisible(true);
193
- poller?.resume();
194
- });
195
- pi.on("session_shutdown", async () => {
196
- await poller?.stop();
197
- poller = undefined;
198
- activeContext = undefined;
199
- });
200
- }
package/src/history.ts DELETED
@@ -1,112 +0,0 @@
1
- import type { ControlPlaneClient } from "./control-plane";
2
- import { ConversationGoneError } from "./control-plane";
3
- import {
4
- type ConversationEntryData,
5
- HISTORY_ENTRY_TYPE,
6
- type HistoryNoticeData,
7
- NOTICE_ENTRY_TYPE,
8
- } from "./renderers";
9
-
10
- export interface ReplayOptions {
11
- pageLimit?: number;
12
- maxMessages?: number;
13
- maxPages?: number;
14
- }
15
-
16
- export interface ReplayOutcome {
17
- replayed: number;
18
- total: number;
19
- truncated: boolean;
20
- gone: boolean;
21
- }
22
-
23
- export interface EntrySink {
24
- appendEntry<T = unknown>(customType: string, data?: T): void;
25
- }
26
-
27
- /**
28
- * Replays the workspace's durable conversation into the local session as
29
- * custom entries. The server transcript is the source of truth: this runs on
30
- * every attach, and entries never participate in LLM context.
31
- */
32
- interface TranscriptTail {
33
- tail: ConversationEntryData[];
34
- total: number;
35
- pages: number;
36
- exhaustedPages: boolean;
37
- }
38
-
39
- async function collectTranscriptTail(
40
- controlPlane: ControlPlaneClient,
41
- workspaceId: string,
42
- options: Required<ReplayOptions>,
43
- ): Promise<TranscriptTail> {
44
- const tail: ConversationEntryData[] = [];
45
- let total = 0;
46
- let pages = 0;
47
- let cursor: string | undefined;
48
- while (pages < options.maxPages) {
49
- const page = await controlPlane.conversations.list(workspaceId, {
50
- cursor,
51
- limit: options.pageLimit,
52
- });
53
- pages += 1;
54
- for (const message of page.items) {
55
- total += 1;
56
- tail.push({
57
- role: message.role,
58
- content: message.content,
59
- seq: message.seq,
60
- occurred_at: message.occurred_at,
61
- kind: message.metadata?.kind,
62
- metadata: message.metadata,
63
- });
64
- if (tail.length > options.maxMessages) tail.shift();
65
- }
66
- if (page.nextCursor === null) return { tail, total, pages, exhaustedPages: false };
67
- cursor = page.nextCursor;
68
- }
69
- return { tail, total, pages, exhaustedPages: true };
70
- }
71
-
72
- export async function replayHistory(
73
- pi: EntrySink,
74
- controlPlane: ControlPlaneClient,
75
- workspaceId: string,
76
- options: ReplayOptions = {},
77
- ): Promise<ReplayOutcome> {
78
- let transcript: TranscriptTail;
79
- try {
80
- transcript = await collectTranscriptTail(controlPlane, workspaceId, {
81
- pageLimit: options.pageLimit ?? 200,
82
- maxMessages: options.maxMessages ?? 1000,
83
- maxPages: options.maxPages ?? 50,
84
- });
85
- } catch (error) {
86
- if (error instanceof ConversationGoneError) {
87
- pi.appendEntry<HistoryNoticeData>(NOTICE_ENTRY_TYPE, {
88
- text:
89
- error.code === "conversation.deleted"
90
- ? "conversation history was deleted"
91
- : "conversation history has expired",
92
- level: "warning",
93
- });
94
- return { replayed: 0, total: 0, truncated: false, gone: true };
95
- }
96
- throw error;
97
- }
98
-
99
- const { tail, total, pages, exhaustedPages } = transcript;
100
- const truncated = total > tail.length || exhaustedPages;
101
- if (truncated) {
102
- pi.appendEntry<HistoryNoticeData>(NOTICE_ENTRY_TYPE, {
103
- text: exhaustedPages
104
- ? `history partially replayed (stopped after ${pages} pages)`
105
- : `showing last ${tail.length} of ${total} messages`,
106
- });
107
- }
108
- for (const message of tail) {
109
- pi.appendEntry<ConversationEntryData>(HISTORY_ENTRY_TYPE, message);
110
- }
111
- return { replayed: tail.length, total, truncated, gone: false };
112
- }
package/src/launch.ts DELETED
@@ -1,85 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, resolve, sep } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
-
5
- export interface PiInvocation {
6
- command: string;
7
- args: string[];
8
- env: NodeJS.ProcessEnv;
9
- }
10
-
11
- export interface ResolveOptions {
12
- env?: NodeJS.ProcessEnv;
13
- argv?: string[];
14
- execPath?: string;
15
- resolvePath?: (specifier: string) => string;
16
- }
17
-
18
- const PI_FLAGS = [
19
- "--provider",
20
- "pocketcoder-agentapi",
21
- "--model",
22
- "remote-agent",
23
- "--api-key",
24
- "local-ui",
25
- "--no-tools",
26
- "--no-extensions",
27
- "--no-skills",
28
- "--no-context-files",
29
- "--no-prompt-templates",
30
- "--no-session",
31
- "--offline",
32
- ];
33
-
34
- function piBinPath(resolvePath: (specifier: string) => string): string {
35
- // The package's exports map hides ./package.json, so locate the package
36
- // root from its resolved main entry instead.
37
- const entry = resolvePath("@earendil-works/pi-coding-agent");
38
- const marker = `${sep}pi-coding-agent${sep}`;
39
- const index = entry.lastIndexOf(marker);
40
- if (index === -1) {
41
- throw new Error(`could not locate @earendil-works/pi-coding-agent from ${entry}`);
42
- }
43
- const packageRoot = entry.slice(0, index + marker.length - 1);
44
- const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as {
45
- bin?: Record<string, string>;
46
- };
47
- const bin = manifest.bin?.pi;
48
- if (!bin) throw new Error("@earendil-works/pi-coding-agent does not declare a pi bin");
49
- return resolve(packageRoot, bin);
50
- }
51
-
52
- function extensionPath(): string {
53
- // Works from both src/bin.ts (dev) and dist/bin.js (published): the
54
- // package root is one directory up in both layouts.
55
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
56
- return resolve(packageRoot, "src/extension.ts");
57
- }
58
-
59
- export function resolvePiInvocation(options: ResolveOptions = {}): PiInvocation {
60
- const env = options.env ?? process.env;
61
- if (!env.POCKETCODER_KEY) {
62
- throw new Error(
63
- "POCKETCODER_KEY is required. Issue a machine key with `pcd keys issue` and export it, along with POCKETCODER_URL for your PocketCoder server.",
64
- );
65
- }
66
- // The pi package's exports map only defines the "import" condition, so
67
- // resolution must go through ESM resolve, not createRequire.
68
- const resolvePath =
69
- options.resolvePath ?? ((specifier: string) => fileURLToPath(import.meta.resolve(specifier)));
70
- const childEnv: NodeJS.ProcessEnv = { ...env };
71
- // Pi must never talk to a model provider directly; every turn goes
72
- // through the PocketCoder relay.
73
- delete childEnv.OPENAI_API_KEY;
74
- return {
75
- command: options.execPath ?? process.execPath,
76
- args: [
77
- piBinPath(resolvePath),
78
- ...PI_FLAGS,
79
- "--extension",
80
- extensionPath(),
81
- ...(options.argv ?? []),
82
- ],
83
- env: childEnv,
84
- };
85
- }
package/src/renderers.ts DELETED
@@ -1,76 +0,0 @@
1
- import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
3
- import { splitAttachmentManifest } from "@pstdio/pocketcoder-client";
4
-
5
- export const HISTORY_ENTRY_TYPE = "pocketcoder-conversation";
6
- export const NOTICE_ENTRY_TYPE = "pocketcoder-history-notice";
7
-
8
- export interface ConversationEntryData {
9
- role: string;
10
- content: string;
11
- seq: number;
12
- occurred_at: string;
13
- /** Reserved for future typed payloads (tool_use, permission_request, ...). */
14
- kind?: string;
15
- metadata?: Record<string, string>;
16
- }
17
-
18
- export interface HistoryNoticeData {
19
- text: string;
20
- level?: "info" | "warning";
21
- }
22
-
23
- /** The slice of Pi's Theme the formatters use; Theme satisfies it structurally. */
24
- export interface ThemeLike {
25
- fg(color: ThemeColor, text: string): string;
26
- bold(text: string): string;
27
- }
28
-
29
- function roleHeader(data: ConversationEntryData, theme: ThemeLike): string {
30
- const time = data.occurred_at.replace("T", " ").replace(/\.\d+Z?$|Z$/, "");
31
- return theme.fg("muted", `${data.role} · ${time}`);
32
- }
33
-
34
- // User messages may end in the generated attachment manifest; history shows
35
- // the attachment names instead of the raw markup and managed paths.
36
- function userBody(content: string, theme: ThemeLike): string {
37
- const { text, attachments } = splitAttachmentManifest(content);
38
- const body = theme.fg("userMessageText", text);
39
- if (!attachments) return body;
40
- const list = attachments
41
- .map((attachment) => theme.fg("muted", `⌁ ${attachment.name} (${attachment.size_bytes} bytes)`))
42
- .join("\n");
43
- return `${body}\n${list}`;
44
- }
45
-
46
- export function formatConversationMessage(data: ConversationEntryData, theme: ThemeLike): string {
47
- switch (data.kind ?? "text") {
48
- // Future typed kinds (tool_use, permission_request, ...) become new cases;
49
- // unknown kinds deliberately fall through to the text rendering.
50
- default: {
51
- const header = roleHeader(data, theme);
52
- const body =
53
- data.role === "user"
54
- ? userBody(data.content, theme)
55
- : data.role === "assistant"
56
- ? data.content
57
- : theme.fg("dim", data.content);
58
- return `${header}\n${body}`;
59
- }
60
- }
61
- }
62
-
63
- export function formatHistoryNotice(data: HistoryNoticeData, theme: ThemeLike): string {
64
- return theme.fg(data.level === "warning" ? "warning" : "muted", data.text);
65
- }
66
-
67
- export function registerConversationRenderers(pi: ExtensionAPI): void {
68
- pi.registerEntryRenderer<ConversationEntryData>(HISTORY_ENTRY_TYPE, (entry, _options, theme) => {
69
- if (!entry.data) return undefined;
70
- return new Text(formatConversationMessage(entry.data, theme), 1, 0);
71
- });
72
- pi.registerEntryRenderer<HistoryNoticeData>(NOTICE_ENTRY_TYPE, (entry, _options, theme) => {
73
- if (!entry.data) return undefined;
74
- return new Text(formatHistoryNotice(entry.data, theme), 1, 0);
75
- });
76
- }