@quandev104/pi-style 0.2.1 → 0.2.3
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/CHANGELOG.md +21 -0
- package/README.md +8 -3
- package/dist/extensions/pi-style.js +1328 -333
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +8 -0
- package/extension-src/pi-style/app/runtime.ts +33 -4
- package/extension-src/pi-style/domain/config-normalization.ts +11 -1
- package/extension-src/pi-style/domain/config-types.ts +12 -0
- package/extension-src/pi-style/domain/status-renderer.ts +41 -9
- package/extension-src/pi-style/domain/status.ts +28 -12
- package/extension-src/pi-style/domain/theme.ts +32 -1
- package/extension-src/pi-style/features/editor/index.ts +144 -5
- package/extension-src/pi-style/features/messages/image-input.ts +205 -0
- package/extension-src/pi-style/features/messages/image-preview.ts +288 -0
- package/extension-src/pi-style/features/messages/index.ts +302 -61
- package/extension-src/pi-style/features/messages/render-config.ts +36 -0
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
- package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
- package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
- package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
- package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
- package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/index.ts +55 -2
- package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +70 -4
- package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
- package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
- package/extension-src/pi-style/shared/pending-images.ts +199 -0
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
- package/extension-src/pi-style/features/.gitkeep +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { StatusSnapshot } from "../domain/status.js";
|
|
3
|
+
import { resolvePendingImageMarkers, transformClipboardImages } from "../features/messages/image-input.js";
|
|
4
|
+
import {
|
|
5
|
+
flushImagePreviewEntry,
|
|
6
|
+
type ImagePreviewEntryData,
|
|
7
|
+
registerImagePreviewSurface,
|
|
8
|
+
stageImagePreviewData,
|
|
9
|
+
} from "../features/messages/image-preview.js";
|
|
3
10
|
import { closeActiveBatch } from "../features/tools/boxed/batch.js";
|
|
4
11
|
import {
|
|
5
12
|
beginAgentRun,
|
|
@@ -7,6 +14,7 @@ import {
|
|
|
7
14
|
invalidateTurnMembers,
|
|
8
15
|
rebuildTurnRegistryFromEntries,
|
|
9
16
|
registerTurnFromMessage,
|
|
17
|
+
releaseTurnInvalidators,
|
|
10
18
|
} from "../features/tools/boxed/turn-summary.js";
|
|
11
19
|
import { requestToolPresentationRender } from "../features/tools/index.js";
|
|
12
20
|
import { registerPiStyleCommand } from "./commands.js";
|
|
@@ -63,6 +71,27 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
63
71
|
pi.registerFlag("pi-style-ascii", { type: "boolean", description: "Use ASCII pi-style markers" });
|
|
64
72
|
const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
|
|
65
73
|
registerPiStyleCommand(pi, coordinator.app);
|
|
74
|
+
// User-prompt image previews (ADR 0008): the entry renderer must be
|
|
75
|
+
// registered before the first entry of this type can exist — the host drops
|
|
76
|
+
// entries whose customType has no renderer — and once at extension load is
|
|
77
|
+
// enough (persisted entries from previous sessions render on resume).
|
|
78
|
+
registerImagePreviewSurface(pi);
|
|
79
|
+
// User-prompt image previews (ADR 0008): stage at `before_agent_start` (the
|
|
80
|
+
// only event carrying the prompt's `images`), flush as a display-only entry
|
|
81
|
+
// at the first `message_start(assistant)`. Appending at before_agent_start
|
|
82
|
+
// would land the entry ABOVE the user message — the user message enters the
|
|
83
|
+
// feed only when UI listeners process message_start(user) and persists at
|
|
84
|
+
// message_end(user), both after extension handlers. At the first assistant
|
|
85
|
+
// message the user message is already rendered and persisted, so the host
|
|
86
|
+
// inserts the entry below it (spliced before the streaming component) and
|
|
87
|
+
// the session file records user → preview → assistant for identical resume.
|
|
88
|
+
// Edge: a steered prompt arriving before the first flush overwrites the
|
|
89
|
+
// staged slot (last write wins) — steer-with-image is a rare corner.
|
|
90
|
+
let stagedImagePreview: ImagePreviewEntryData | undefined;
|
|
91
|
+
pi.on("before_agent_start", (event) => {
|
|
92
|
+
const staged = stageImagePreviewData(event.images ?? []);
|
|
93
|
+
if (staged) stagedImagePreview = staged;
|
|
94
|
+
});
|
|
66
95
|
pi.on("session_start", async (event, ctx) => {
|
|
67
96
|
resetUsageFromSessionCache(ctx.sessionManager);
|
|
68
97
|
// Pi only activates read/bash/edit/write by default; grep/find/ls are
|
|
@@ -80,7 +109,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
80
109
|
// (user request → agent_end), not pi's per-message turn_end.
|
|
81
110
|
beginAgentRun();
|
|
82
111
|
});
|
|
83
|
-
pi.on("input", (event, _ctx) => {
|
|
112
|
+
pi.on("input", async (event, _ctx) => {
|
|
84
113
|
coordinator.app.runtime.current?.dismissStartup();
|
|
85
114
|
// Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
|
|
86
115
|
// command but falls through to normal message submission when the bang has
|
|
@@ -96,6 +125,23 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
96
125
|
if (trimmed.slice(bangLength).trim() === "") return { action: "handled" };
|
|
97
126
|
}
|
|
98
127
|
}
|
|
128
|
+
// Clipboard image input (ADR 0009): `[Image #N]` markers resolve for EVERY
|
|
129
|
+
// submit source — a submit is a submit (image-paste semantics); consuming
|
|
130
|
+
// the registry one-shot on rpc/extension submits too keeps a pasted marker
|
|
131
|
+
// from dangling into a later, unrelated prompt. No-op when the registry is
|
|
132
|
+
// empty. Raw clipboard path tokens, in contrast, only ever come from the
|
|
133
|
+
// interactive editor's built-in paste — that upgrade stays interactive-only.
|
|
134
|
+
// Combined images flow on to before_agent_start, where the ADR 0008 preview
|
|
135
|
+
// entry picks them up.
|
|
136
|
+
const markerResult = await resolvePendingImageMarkers(event.text);
|
|
137
|
+
const pathResult = event.source === "interactive" ? await transformClipboardImages(event.text) : undefined;
|
|
138
|
+
if (markerResult || pathResult) {
|
|
139
|
+
return {
|
|
140
|
+
action: "transform" as const,
|
|
141
|
+
text: pathResult?.text ?? event.text,
|
|
142
|
+
images: [...(markerResult?.images ?? []), ...(pathResult?.images ?? [])],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
99
145
|
return undefined;
|
|
100
146
|
});
|
|
101
147
|
pi.on("tool_execution_start", () => coordinator.app.runtime.current?.dismissStartup());
|
|
@@ -113,10 +159,16 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
113
159
|
pi.on("session_info_changed", (event) =>
|
|
114
160
|
coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
|
|
115
161
|
);
|
|
116
|
-
pi.on("message_start", () => {
|
|
162
|
+
pi.on("message_start", (event) => {
|
|
117
163
|
// A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
|
|
118
164
|
// new message start a fresh batch instead of joining the previous one.
|
|
119
165
|
closeActiveBatch();
|
|
166
|
+
// Flush the staged image preview below the just-rendered user message
|
|
167
|
+
// (ADR 0008 ordering — see the before_agent_start comment above).
|
|
168
|
+
if (stagedImagePreview && event.message?.role === "assistant") {
|
|
169
|
+
flushImagePreviewEntry(pi, stagedImagePreview);
|
|
170
|
+
stagedImagePreview = undefined;
|
|
171
|
+
}
|
|
120
172
|
// grep/bash tree panels are NOT cleared here: historical panels must keep
|
|
121
173
|
// their state so Pi re-renders of previous messages (scroll/resume) stay
|
|
122
174
|
// intact. Only session boundaries reset them (session-coordinator).
|
|
@@ -142,6 +194,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
142
194
|
const run = finishAgentRun();
|
|
143
195
|
if (run) {
|
|
144
196
|
invalidateTurnMembers(run);
|
|
197
|
+
releaseTurnInvalidators(run);
|
|
145
198
|
requestToolPresentationRender();
|
|
146
199
|
}
|
|
147
200
|
});
|
|
@@ -3,6 +3,8 @@ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import type { ConfigFilePort } from "../app/config-storage.js";
|
|
4
4
|
import { createPiStyleApp, type PiStyleApp } from "../app/index.js";
|
|
5
5
|
import { resolveTheme } from "../domain/theme.js";
|
|
6
|
+
import { resetPendingImageRegistry } from "../features/messages/image-input.js";
|
|
7
|
+
import { setMessagesRenderConfig } from "../features/messages/render-config.js";
|
|
6
8
|
import { setSpecialBlockTheme } from "../features/messages/special-blocks.js";
|
|
7
9
|
import { setBashExecutionTheme } from "../features/tools/bash-execution.js";
|
|
8
10
|
import { resetBashTreeRegistry } from "../features/tools/boxed/bash.js";
|
|
@@ -95,6 +97,14 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
95
97
|
*/
|
|
96
98
|
const applyMessagesConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
|
|
97
99
|
sessionUi?.setHiddenThinkingLabel?.(config.messages.hideThinkingLabel ? "" : undefined);
|
|
100
|
+
// User-prompt image previews (ADR 0008) + clipboard image input (ADR
|
|
101
|
+
// 0009): the leaves gate their respective sides (preview: stage+render;
|
|
102
|
+
// clipboard: input transform) and size the preview images.
|
|
103
|
+
setMessagesRenderConfig({
|
|
104
|
+
showImagePreviews: config.messages.showImagePreviews,
|
|
105
|
+
clipboardImages: config.messages.clipboardImages,
|
|
106
|
+
previewMaxWidth: config.messages.previewMaxWidth,
|
|
107
|
+
});
|
|
98
108
|
};
|
|
99
109
|
/**
|
|
100
110
|
* Auto-apply the configured pi-style theme (default "titanium") once per TUI
|
|
@@ -176,6 +186,8 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
176
186
|
resetBatchRegistry();
|
|
177
187
|
resetGrepRegistry();
|
|
178
188
|
resetBashTreeRegistry();
|
|
189
|
+
// Clipboard image pending markers (ADR 0009) never cross sessions.
|
|
190
|
+
resetPendingImageRegistry();
|
|
179
191
|
// Turn summaries (ADR 0007): rebuild the registry from session content so
|
|
180
192
|
// restored/forked history renders collapsed before the first render pass
|
|
181
193
|
// (deterministic; no in-process turn_end events needed).
|
|
@@ -253,6 +265,7 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
253
265
|
resetBatchRegistry();
|
|
254
266
|
resetGrepRegistry();
|
|
255
267
|
resetBashTreeRegistry();
|
|
268
|
+
resetPendingImageRegistry();
|
|
256
269
|
resetTurnRegistry();
|
|
257
270
|
stopAllElapsedTickers();
|
|
258
271
|
app.sessionShutdown();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
1
3
|
function isFinal(byte: string): boolean {
|
|
2
4
|
return byte >= "@" && byte <= "~";
|
|
3
5
|
}
|
|
@@ -91,8 +93,12 @@ export function stripAnsi(value: string): string {
|
|
|
91
93
|
}
|
|
92
94
|
return output;
|
|
93
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Terminal-correct visible width: delegates to pi-tui (ANSI-stripping,
|
|
98
|
+
* ASCII fast path, per-string cache, wide chars = 2 columns, tabs = 3).
|
|
99
|
+
*/
|
|
94
100
|
export function visibleWidth(value: string): number {
|
|
95
|
-
return
|
|
101
|
+
return tuiVisibleWidth(value);
|
|
96
102
|
}
|
|
97
103
|
export function resetAnsi(value: string): string {
|
|
98
104
|
return `${value}\x1b[0m`;
|
|
@@ -105,9 +111,10 @@ export function fitAnsiWidth(value: string, width: number, ellipsis = "…"): st
|
|
|
105
111
|
export function truncateAnsi(value: string, width: number, ellipsis = "…"): string {
|
|
106
112
|
if (width <= 0) return "";
|
|
107
113
|
if (visibleWidth(value) <= width) return resetAnsi(value);
|
|
114
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
108
115
|
let output = "";
|
|
109
116
|
let visible = 0;
|
|
110
|
-
for (let i = 0; i < value.length && visible < width -
|
|
117
|
+
for (let i = 0; i < value.length && visible < width - ellipsisWidth; i++) {
|
|
111
118
|
if (value.charCodeAt(i) === 27) {
|
|
112
119
|
const start = i;
|
|
113
120
|
i++;
|
|
@@ -125,12 +132,17 @@ export function wrapAnsi(value: string, width: number): string[] {
|
|
|
125
132
|
if (width <= 0) return [""];
|
|
126
133
|
const lines: string[] = [];
|
|
127
134
|
let line = "";
|
|
135
|
+
let lineWidth = 0;
|
|
128
136
|
for (const word of value.split(/\s+/)) {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
const wordWidth = visibleWidth(word);
|
|
138
|
+
const nextWidth = line ? lineWidth + 1 + wordWidth : wordWidth;
|
|
139
|
+
if (nextWidth <= width) {
|
|
140
|
+
line = line ? `${line} ${word}` : word;
|
|
141
|
+
lineWidth = nextWidth;
|
|
142
|
+
} else {
|
|
132
143
|
if (line) lines.push(resetAnsi(line));
|
|
133
144
|
line = truncateAnsi(word, width);
|
|
145
|
+
lineWidth = visibleWidth(line);
|
|
134
146
|
}
|
|
135
147
|
}
|
|
136
148
|
if (line || lines.length === 0) lines.push(resetAnsi(line));
|
|
@@ -149,12 +149,63 @@ export function countLines(text: string): number {
|
|
|
149
149
|
return normalized.split("\n").length;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// Word-character membership table powering countWords. ASCII and the UTF-16
|
|
153
|
+
// surrogate range are initialized eagerly; every other BMP code point is
|
|
154
|
+
// resolved through the Unicode letter/digit class on first sight and memoized,
|
|
155
|
+
// so repeat scans are pure table lookups. The per-code-point regex dispatch
|
|
156
|
+
// this replaces measured ~1.9ms per 90KB output on every boxed footer render;
|
|
157
|
+
// note that String.match with a \p{L}\p{N} class is no faster on V8 — the
|
|
158
|
+
// memoized scan is the only variant that hit the <0.2ms budget.
|
|
159
|
+
const WORD_CP_UNKNOWN = 255;
|
|
160
|
+
const WORD_CP_SURROGATE = 254;
|
|
161
|
+
const WORD_CP_CLASS = new Uint8Array(0x10000).fill(WORD_CP_UNKNOWN);
|
|
162
|
+
for (let code = 0x30; code <= 0x39; code++) WORD_CP_CLASS[code] = 1; // 0-9
|
|
163
|
+
for (let code = 0x41; code <= 0x5a; code++) WORD_CP_CLASS[code] = 1; // A-Z
|
|
164
|
+
for (let code = 0x61; code <= 0x7a; code++) WORD_CP_CLASS[code] = 1; // a-z
|
|
165
|
+
WORD_CP_CLASS[0x27] = 1; // '
|
|
166
|
+
WORD_CP_CLASS[0x2d] = 1; // -
|
|
167
|
+
WORD_CP_CLASS[0x5f] = 1; // _
|
|
168
|
+
WORD_CP_CLASS.fill(WORD_CP_SURROGATE, 0xd800, 0xe000);
|
|
169
|
+
const NON_ASCII_WORD_RE = /[\p{L}\p{N}]/u;
|
|
170
|
+
const ASTRAL_WORD_CP = new Map<number, 0 | 1>();
|
|
171
|
+
|
|
172
|
+
function astralWordMembership(codePoint: number): 0 | 1 {
|
|
173
|
+
const cached = ASTRAL_WORD_CP.get(codePoint);
|
|
174
|
+
if (cached !== undefined) return cached;
|
|
175
|
+
const membership: 0 | 1 = NON_ASCII_WORD_RE.test(String.fromCodePoint(codePoint)) ? 1 : 0;
|
|
176
|
+
ASTRAL_WORD_CP.set(codePoint, membership);
|
|
177
|
+
return membership;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Counts words as maximal runs of word characters (letters, digits,
|
|
181
|
+
* underscore, apostrophe, hyphen) — identical counts to a per-code-point
|
|
182
|
+
* `\p{L}\p{N}_'-` class test, in one table-driven pass with no allocations. */
|
|
152
183
|
export function countWords(text: string): number {
|
|
184
|
+
const len = text.length;
|
|
153
185
|
let count = 0;
|
|
154
|
-
let inWord =
|
|
155
|
-
for (
|
|
156
|
-
const
|
|
157
|
-
|
|
186
|
+
let inWord: number = 0;
|
|
187
|
+
for (let i = 0; i < len; i++) {
|
|
188
|
+
const code = text.charCodeAt(i);
|
|
189
|
+
const membership = WORD_CP_CLASS[code] ?? 0;
|
|
190
|
+
let isWord: number;
|
|
191
|
+
if (membership <= 1) {
|
|
192
|
+
isWord = membership;
|
|
193
|
+
} else if (membership === WORD_CP_SURROGATE) {
|
|
194
|
+
const next = i + 1 < len ? text.charCodeAt(i + 1) : 0;
|
|
195
|
+
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
|
|
196
|
+
// Astral word characters (e.g. mathematical alphanumerics) count as
|
|
197
|
+
// one code point, exactly like the previous code-point iteration.
|
|
198
|
+
isWord = astralWordMembership(0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00));
|
|
199
|
+
i++; // consumed the pair's low surrogate half
|
|
200
|
+
} else {
|
|
201
|
+
isWord = 0; // lone surrogate half: never a word character
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// First sight of this non-ASCII BMP code point: resolve once, memoize.
|
|
205
|
+
isWord = NON_ASCII_WORD_RE.test(String.fromCharCode(code)) ? 1 : 0;
|
|
206
|
+
WORD_CP_CLASS[code] = isWord;
|
|
207
|
+
}
|
|
208
|
+
count += isWord & (inWord ^ 1);
|
|
158
209
|
inWord = isWord;
|
|
159
210
|
}
|
|
160
211
|
return count;
|
|
@@ -210,6 +261,9 @@ const BOX_DIVIDER_RIGHT = "┤";
|
|
|
210
261
|
/** Dash run before the right corner when a right-side border label is present. */
|
|
211
262
|
const BOX_LABELED_RIGHT_DASH_MIN = 3;
|
|
212
263
|
const BOX_WIDTH_CACHE = new Map<string, number>();
|
|
264
|
+
/** Hard cap for BOX_WIDTH_CACHE; the oldest entry is evicted beyond this. Keys
|
|
265
|
+
* embed full bash commands, so the cache must stay bounded across a session. */
|
|
266
|
+
const BOX_WIDTH_CACHE_MAX_ENTRIES = 512;
|
|
213
267
|
|
|
214
268
|
export function boxWidth(width: number): number {
|
|
215
269
|
return Math.max(BOX_MIN_WIDTH, width);
|
|
@@ -236,6 +290,13 @@ function _tightBoxWidth(
|
|
|
236
290
|
if (!widthKey) return measuredWidth;
|
|
237
291
|
const cachedWidth = BOX_WIDTH_CACHE.get(widthKey) ?? 0;
|
|
238
292
|
const nextWidth = Math.min(boxWidth(availableWidth), Math.max(cachedWidth, measuredWidth));
|
|
293
|
+
// Bounded LRU: Map preserves insertion order, so delete+set refreshes the
|
|
294
|
+
// key's recency and the first key is the oldest evict candidate.
|
|
295
|
+
BOX_WIDTH_CACHE.delete(widthKey);
|
|
296
|
+
if (BOX_WIDTH_CACHE.size >= BOX_WIDTH_CACHE_MAX_ENTRIES) {
|
|
297
|
+
const oldestKey = BOX_WIDTH_CACHE.keys().next().value;
|
|
298
|
+
if (oldestKey !== undefined) BOX_WIDTH_CACHE.delete(oldestKey);
|
|
299
|
+
}
|
|
239
300
|
BOX_WIDTH_CACHE.set(widthKey, nextWidth);
|
|
240
301
|
return nextWidth;
|
|
241
302
|
}
|
|
@@ -244,6 +305,11 @@ export function boxedToolWidthKey(toolName: string, detail: string): string {
|
|
|
244
305
|
return `${toolName}:${detail}`;
|
|
245
306
|
}
|
|
246
307
|
|
|
308
|
+
/** Test-only debug view of the bounded box width cache. */
|
|
309
|
+
export function __getBoxWidthCacheDebugState(): { size: number } {
|
|
310
|
+
return { size: BOX_WIDTH_CACHE.size };
|
|
311
|
+
}
|
|
312
|
+
|
|
247
313
|
export function formatToolName(toolName: string): string {
|
|
248
314
|
const spaced = toolName
|
|
249
315
|
.replace(/[_-]+/g, " ")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Clipboard-paste path pattern (ADR 0009).
|
|
2
|
+
//
|
|
3
|
+
// Pi's built-in Ctrl+V (app.clipboard.pasteImage) materializes clipboard
|
|
4
|
+
// images as `<os.tmpdir()>/pi-clipboard-<uuid>.<ext>` files and inserts that
|
|
5
|
+
// absolute path into the editor as text. This module owns the shape of those
|
|
6
|
+
// artifacts so the editor feature (marker interception) and the messages
|
|
7
|
+
// feature (submit transform) share one definition without cross-feature
|
|
8
|
+
// imports (the pattern is a shared primitive).
|
|
9
|
+
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
/** crypto.randomUUID() shape: lowercase v4, 8-4-4-4-12 hex groups. */
|
|
13
|
+
export const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
14
|
+
|
|
15
|
+
/** Extensions Pi's clipboard paste writes (extensionForImageMimeType / png). */
|
|
16
|
+
export const CLIPBOARD_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"] as const;
|
|
17
|
+
|
|
18
|
+
/** Marker inserted by the editor interception for a pending pasted image. */
|
|
19
|
+
export function clipboardImageMarker(index: number): string {
|
|
20
|
+
return `[Image #${index}]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `[Image #N]` marker token (editor interception; ADR 0009 marker surface). */
|
|
24
|
+
export const CLIPBOARD_IMAGE_MARKER_PATTERN = /\[Image #([0-9]+)\]/g;
|
|
25
|
+
|
|
26
|
+
function escapeRegExp(text: string): string {
|
|
27
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Memoized regexes keyed by tmp root: tmpdir() call + escapeRegExp + regex
|
|
31
|
+
* compile happen once per root instead of on every submit/paste probe.
|
|
32
|
+
* Bounded because tests inject arbitrary roots — past the cap the oldest
|
|
33
|
+
* entry is evicted. Sharing one global instance per root is safe:
|
|
34
|
+
* String.matchAll clones the regex into its iterator (original untouched),
|
|
35
|
+
* and String.match/.replace with /g reset lastIndex themselves. */
|
|
36
|
+
const clipboardPathRegexCache = new Map<string, RegExp>();
|
|
37
|
+
const CLIPBOARD_PATH_REGEX_CACHE_MAX = 16;
|
|
38
|
+
|
|
39
|
+
/** Regex matching Pi clipboard-paste paths under the given tmpdir (default: process).
|
|
40
|
+
* Boundaries are non-alphanumeric (not whitespace-only): users type
|
|
41
|
+
* punctuation straight after a pasted path (`<path>, đây là...`), so a
|
|
42
|
+
* whitespace-or-EOL lookahead would miss the most common real shapes.
|
|
43
|
+
* Alphanumeric neighbors still reject glued text (`x<path>`). */
|
|
44
|
+
export function clipboardPathRegex(tmpRoot: string = tmpdir()): RegExp {
|
|
45
|
+
const cached = clipboardPathRegexCache.get(tmpRoot);
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
const exts = CLIPBOARD_IMAGE_EXTENSIONS.join("|");
|
|
48
|
+
const regex = new RegExp(
|
|
49
|
+
`(?<![a-zA-Z0-9])(${escapeRegExp(tmpRoot)}/pi-clipboard-${UUID_PATTERN}\\.(${exts}))(?![a-zA-Z0-9])`,
|
|
50
|
+
"g",
|
|
51
|
+
);
|
|
52
|
+
if (clipboardPathRegexCache.size >= CLIPBOARD_PATH_REGEX_CACHE_MAX) {
|
|
53
|
+
const oldest = clipboardPathRegexCache.keys().next().value;
|
|
54
|
+
if (oldest !== undefined) clipboardPathRegexCache.delete(oldest);
|
|
55
|
+
}
|
|
56
|
+
clipboardPathRegexCache.set(tmpRoot, regex);
|
|
57
|
+
return regex;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test seam: clear the memoization cache (isolation between suites). */
|
|
61
|
+
export function resetClipboardPathRegexCache(): void {
|
|
62
|
+
clipboardPathRegexCache.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Test seam: current memoized-regex count (memoization observability). */
|
|
66
|
+
export function __regexCacheSizeForTest(): number {
|
|
67
|
+
return clipboardPathRegexCache.size;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when the whole string is exactly one clipboard-paste path token —
|
|
71
|
+
* the shape Pi's handleClipboardPaste passes to insertTextAtCursor. */
|
|
72
|
+
export function isSingleClipboardImagePath(text: string, tmpRoot?: string): boolean {
|
|
73
|
+
if (!text || /\s/.test(text)) return false;
|
|
74
|
+
const match = text.match(clipboardPathRegex(tmpRoot ?? tmpdir()));
|
|
75
|
+
return match !== null && match.length === 1 && match[0] === text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** All distinct clipboard-paste path tokens in the text (verbatim matches).
|
|
79
|
+
* `tmpRoot` defaults to the process tmpdir; tests inject a fixed root. */
|
|
80
|
+
export function extractClipboardImageTokens(text: string, tmpRoot?: string): string[] {
|
|
81
|
+
const tokens = new Set<string>();
|
|
82
|
+
for (const match of text.matchAll(clipboardPathRegex(tmpRoot ?? tmpdir()))) {
|
|
83
|
+
const token = match[1];
|
|
84
|
+
if (token) tokens.add(token);
|
|
85
|
+
}
|
|
86
|
+
return [...tokens];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MARKER_AT_END_PATTERN = /\[Image #([0-9]+)\]( ?)$/;
|
|
90
|
+
|
|
91
|
+
/** When `text` ends with a clipboard image marker, return its index and total
|
|
92
|
+
* length (including the trailing space when present). Used by the editor's
|
|
93
|
+
* atomic backspace: the marker directly before the cursor deletes as a unit. */
|
|
94
|
+
export function clipboardMarkerAtEnd(text: string): { index: number; length: number } | undefined {
|
|
95
|
+
const match = MARKER_AT_END_PATTERN.exec(text);
|
|
96
|
+
if (!match) return undefined;
|
|
97
|
+
return { index: Number(match[1]), length: match[0].length };
|
|
98
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Sync clipboard-image presence probe (ADR 0009 instant-marker surface).
|
|
2
|
+
//
|
|
3
|
+
// Pi's built-in paste reads the clipboard asynchronously (native module,
|
|
4
|
+
// ~90ms for a screenshot) before it can tell whether the clipboard even holds
|
|
5
|
+
// an image. The native module also exposes a synchronous hasImage() poll —
|
|
6
|
+
// resolving it through Pi's own install lets the editor insert the
|
|
7
|
+
// `[Image #N] ` marker at keystroke time (zero-latency feedback) instead of
|
|
8
|
+
// after the read, while text pastes stay untouched (no marker flash).
|
|
9
|
+
//
|
|
10
|
+
// Resolution mirrors Pi's clipboard-native.js: require "@mariozechner/clipboard"
|
|
11
|
+
// relative to the pi-coding-agent package, where the module is installed.
|
|
12
|
+
// Unresolvable (bundled/aliased hosts, Termux) → null → caller falls back to
|
|
13
|
+
// the artifact-time marker path.
|
|
14
|
+
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
interface ClipboardPresenceModule {
|
|
20
|
+
hasImage(): boolean;
|
|
21
|
+
getImageBinary?(): Promise<Array<number> | Uint8Array>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cachedModule: ClipboardPresenceModule | null | undefined;
|
|
25
|
+
|
|
26
|
+
/** Resolution roots, ordered: the running pi process's own module graph first
|
|
27
|
+
* (process.argv[1] is pi's cli — its install carries @mariozechner/clipboard
|
|
28
|
+
* as an optionalDependency, possibly nested and NOT visible from the
|
|
29
|
+
* extension project's node_modules), then the extension's own graph.
|
|
30
|
+
* argv[1] may be a symlink (bin/pi) — realpath it first so the require root
|
|
31
|
+
* lands inside the real install. */
|
|
32
|
+
function clipboardResolutionRoots(): string[] {
|
|
33
|
+
const roots: string[] = [];
|
|
34
|
+
const argvMain = process.argv[1];
|
|
35
|
+
if (typeof argvMain === "string" && argvMain.startsWith("/")) {
|
|
36
|
+
try {
|
|
37
|
+
roots.push(dirname(realpathSync(argvMain)));
|
|
38
|
+
} catch {
|
|
39
|
+
roots.push(dirname(argvMain));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
roots.push(import.meta.url);
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadClipboardModule(): ClipboardPresenceModule | null {
|
|
47
|
+
if (cachedModule !== undefined) return cachedModule;
|
|
48
|
+
if (process.env.TERMUX_VERSION) {
|
|
49
|
+
cachedModule = null;
|
|
50
|
+
return cachedModule;
|
|
51
|
+
}
|
|
52
|
+
for (const root of clipboardResolutionRoots()) {
|
|
53
|
+
try {
|
|
54
|
+
const require = createRequire(root);
|
|
55
|
+
const resolved = require("@mariozechner/clipboard") as ClipboardPresenceModule;
|
|
56
|
+
if (resolved && typeof resolved.hasImage === "function") {
|
|
57
|
+
cachedModule = resolved;
|
|
58
|
+
return cachedModule;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Try the next resolution root.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
cachedModule = null;
|
|
65
|
+
return cachedModule;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when the system clipboard currently holds an image; null when the
|
|
70
|
+
* native clipboard module is unavailable (caller must not use the result to
|
|
71
|
+
* gate instant feedback — fall back to the async path instead).
|
|
72
|
+
*/
|
|
73
|
+
export function clipboardHasImageSync(): boolean | null {
|
|
74
|
+
const clipboard = loadClipboardModule();
|
|
75
|
+
if (!clipboard || typeof clipboard.hasImage !== "function") return null;
|
|
76
|
+
try {
|
|
77
|
+
return clipboard.hasImage() === true;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the clipboard image bytes directly through the native module (PNG on
|
|
85
|
+
* platforms where the module reports images). Null when unavailable or the
|
|
86
|
+
* clipboard holds no image — callers fall back to artifact-time handling.
|
|
87
|
+
* `skipPresenceProbe`: for callers that already confirmed presence via
|
|
88
|
+
* hasImage() (the owned-paste path probes at keystroke time) — skips the
|
|
89
|
+
* redundant second native probe. Default behavior is unchanged.
|
|
90
|
+
*/
|
|
91
|
+
export async function readClipboardImageBinary(options: { skipPresenceProbe?: boolean } = {}): Promise<{
|
|
92
|
+
bytes: Uint8Array;
|
|
93
|
+
} | null> {
|
|
94
|
+
const clipboard = loadClipboardModule();
|
|
95
|
+
if (!clipboard || typeof clipboard.hasImage !== "function" || typeof clipboard.getImageBinary !== "function") {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (!options.skipPresenceProbe && !clipboard.hasImage()) return null;
|
|
100
|
+
const imageData = await clipboard.getImageBinary();
|
|
101
|
+
if (!imageData || imageData.length === 0) return null;
|
|
102
|
+
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
|
|
103
|
+
return bytes.length > 0 ? { bytes } : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Test seam: forget the cached module resolution. */
|
|
110
|
+
export function resetClipboardPresenceCache(): void {
|
|
111
|
+
cachedModule = undefined;
|
|
112
|
+
}
|