@quandev104/pi-style 0.2.2 → 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 +11 -0
- package/README.md +8 -3
- package/dist/extensions/pi-style.js +674 -49
- 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 +4 -0
- 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 +1 -1
- package/extension-src/pi-style/domain/status.ts +13 -7
- package/extension-src/pi-style/features/editor/index.ts +127 -0
- 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/render-config.ts +36 -0
- package/extension-src/pi-style/pi/index.ts +53 -2
- package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
- 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/package.json +1 -1
- package/extension-src/pi-style/features/.gitkeep +0 -0
|
@@ -49,6 +49,9 @@ const allowedPaths = new Set([
|
|
|
49
49
|
"messages.specialBlocks",
|
|
50
50
|
"messages.hideThinkingLabel",
|
|
51
51
|
"messages.hideInterimText",
|
|
52
|
+
"messages.showImagePreviews",
|
|
53
|
+
"messages.clipboardImages",
|
|
54
|
+
"messages.previewMaxWidth",
|
|
52
55
|
"tools.enabled",
|
|
53
56
|
"tools.style",
|
|
54
57
|
"tools.maxCollapsedLines",
|
|
@@ -82,6 +85,9 @@ function validatePathValue(path: string, value: unknown): boolean {
|
|
|
82
85
|
"messages.specialBlocks",
|
|
83
86
|
"messages.hideThinkingLabel",
|
|
84
87
|
"messages.hideInterimText",
|
|
88
|
+
"messages.showImagePreviews",
|
|
89
|
+
"messages.clipboardImages",
|
|
90
|
+
"messages.previewMaxWidth",
|
|
85
91
|
"tools.enabled",
|
|
86
92
|
"tools.showElapsed",
|
|
87
93
|
"tools.dimOutput",
|
|
@@ -105,6 +111,8 @@ function validatePathValue(path: string, value: unknown): boolean {
|
|
|
105
111
|
return typeof value === "string";
|
|
106
112
|
if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
|
|
107
113
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
114
|
+
if (path === "messages.previewMaxWidth")
|
|
115
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60;
|
|
108
116
|
if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
|
|
109
117
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
110
118
|
if (path.endsWith(".colors") || path.endsWith(".glyphs"))
|
|
@@ -5,6 +5,7 @@ import type { GitCommandRunner } from "../domain/providers.js";
|
|
|
5
5
|
import type { ContextSnapshot, StatusSnapshot } from "../domain/status.js";
|
|
6
6
|
import { normalizeThinkingLevel } from "../domain/status.js";
|
|
7
7
|
import { installEditor } from "../features/editor/index.js";
|
|
8
|
+
import { createClipboardImagePasteSurface } from "../features/messages/image-input.js";
|
|
8
9
|
import {
|
|
9
10
|
installStartup,
|
|
10
11
|
type StartupHost,
|
|
@@ -198,6 +199,9 @@ export function createPiStyleRuntime(
|
|
|
198
199
|
generation,
|
|
199
200
|
initialSnapshot: currentSnapshot,
|
|
200
201
|
isCurrent: () => !disposed,
|
|
202
|
+
// Clipboard image paste surface (ADR 0009): instant `[Image #N] `
|
|
203
|
+
// markers at keystroke time, artifact fallback, atomic backspace.
|
|
204
|
+
clipboardImagePaste: createClipboardImagePasteSurface(),
|
|
201
205
|
});
|
|
202
206
|
if (editor) {
|
|
203
207
|
disposables.add(editor);
|
|
@@ -16,7 +16,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
|
|
|
16
16
|
startup: Object.freeze({ mode: "compact", showResources: false, alwaysExpanded: false }),
|
|
17
17
|
statusLine: Object.freeze({
|
|
18
18
|
enabled: true,
|
|
19
|
-
separator: "
|
|
19
|
+
separator: "|",
|
|
20
20
|
layout: Object.freeze({
|
|
21
21
|
left: ["path", "git", "context_bar", "cost"],
|
|
22
22
|
right: ["model_effort"],
|
|
@@ -34,6 +34,9 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
|
|
|
34
34
|
specialBlocks: true,
|
|
35
35
|
hideThinkingLabel: true,
|
|
36
36
|
hideInterimText: true,
|
|
37
|
+
showImagePreviews: true,
|
|
38
|
+
clipboardImages: true,
|
|
39
|
+
previewMaxWidth: 30,
|
|
37
40
|
}),
|
|
38
41
|
tools: Object.freeze({
|
|
39
42
|
enabled: true,
|
|
@@ -178,6 +181,9 @@ export function normalizeConfig(
|
|
|
178
181
|
specialBlocks: bool(messages.specialBlocks, defaults.messages.specialBlocks),
|
|
179
182
|
hideThinkingLabel: bool(messages.hideThinkingLabel, defaults.messages.hideThinkingLabel),
|
|
180
183
|
hideInterimText: bool(messages.hideInterimText, defaults.messages.hideInterimText),
|
|
184
|
+
showImagePreviews: bool(messages.showImagePreviews, defaults.messages.showImagePreviews),
|
|
185
|
+
clipboardImages: bool(messages.clipboardImages, defaults.messages.clipboardImages),
|
|
186
|
+
previewMaxWidth: boundedInt(messages.previewMaxWidth, defaults.messages.previewMaxWidth, 8, 60),
|
|
181
187
|
}),
|
|
182
188
|
tools: Object.freeze({
|
|
183
189
|
enabled: bool(tools.enabled, defaults.tools.enabled),
|
|
@@ -239,6 +245,8 @@ const BOOL_PATHS = new Set([
|
|
|
239
245
|
"messages.specialBlocks",
|
|
240
246
|
"messages.hideThinkingLabel",
|
|
241
247
|
"messages.hideInterimText",
|
|
248
|
+
"messages.showImagePreviews",
|
|
249
|
+
"messages.clipboardImages",
|
|
242
250
|
"tools.enabled",
|
|
243
251
|
"tools.showElapsed",
|
|
244
252
|
"tools.dimOutput",
|
|
@@ -289,6 +297,8 @@ function validLeaf(path: string, value: unknown): boolean {
|
|
|
289
297
|
return typeof value === "string";
|
|
290
298
|
if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
|
|
291
299
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
300
|
+
if (path === "messages.previewMaxWidth")
|
|
301
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60;
|
|
292
302
|
if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
|
|
293
303
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
294
304
|
if (STRING_ARRAY_PATHS.has(path)) return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
@@ -38,6 +38,12 @@ export interface PiStyleConfig {
|
|
|
38
38
|
specialBlocks?: boolean;
|
|
39
39
|
hideThinkingLabel?: boolean;
|
|
40
40
|
hideInterimText?: boolean;
|
|
41
|
+
/** Inline previews for user-prompt images (ADR 0008); gates append and render. */
|
|
42
|
+
showImagePreviews?: boolean;
|
|
43
|
+
/** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
|
|
44
|
+
clipboardImages?: boolean;
|
|
45
|
+
/** Max cell width per user-prompt image preview (ADR 0008); default 30. */
|
|
46
|
+
previewMaxWidth?: number;
|
|
41
47
|
};
|
|
42
48
|
tools?: {
|
|
43
49
|
enabled?: boolean;
|
|
@@ -97,6 +103,12 @@ export interface NormalizedPiStyleConfig {
|
|
|
97
103
|
specialBlocks: boolean;
|
|
98
104
|
hideThinkingLabel: boolean;
|
|
99
105
|
hideInterimText: boolean;
|
|
106
|
+
/** Inline previews for user-prompt images (ADR 0008); gates append and render. */
|
|
107
|
+
showImagePreviews: boolean;
|
|
108
|
+
/** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
|
|
109
|
+
clipboardImages: boolean;
|
|
110
|
+
/** Max cell width per user-prompt image preview (ADR 0008); default 30. */
|
|
111
|
+
previewMaxWidth: number;
|
|
100
112
|
};
|
|
101
113
|
readonly tools: {
|
|
102
114
|
enabled: boolean;
|
|
@@ -56,7 +56,7 @@ export function renderStatus(
|
|
|
56
56
|
options: StatusRendererOptions,
|
|
57
57
|
): StatusRenderResult {
|
|
58
58
|
if (width <= 0) return { primary: "", lines: [], visibleSegments: [] };
|
|
59
|
-
const separator = options.separator ?? "
|
|
59
|
+
const separator = options.separator ?? "|";
|
|
60
60
|
const padding = options.padding ?? " ";
|
|
61
61
|
const normalized = uniqueLayout(layout);
|
|
62
62
|
const context: SegmentContext = { snapshot, theme: options.theme, options: options.options ?? {}, width };
|
|
@@ -252,12 +252,21 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
|
|
|
252
252
|
const percent = contextPercent(snapshot.context ?? {});
|
|
253
253
|
if (percent === undefined) return { visible: false, content: "" };
|
|
254
254
|
const token = contextBarToken(percent);
|
|
255
|
-
const window = snapshot.context?.windowTokens;
|
|
256
|
-
const label = `ctx${window !== undefined ? ` (${formatTokens(window)})` : ""}:`;
|
|
257
255
|
const width = (options.context_bar?.width as number | undefined) ?? CONTEXT_BAR_WIDTH;
|
|
256
|
+
// Pipe-delimited context block: `[█████░░░░░] | 47% used | 235K/1.0M`.
|
|
257
|
+
// Uses plain `|` (not the tall `│`) so the inline block stays visually short.
|
|
258
|
+
// No boundary pipes: the status renderer's segment separator already
|
|
259
|
+
// delimits the block, and extra pipes would double it up.
|
|
260
|
+
const pipe = theme.apply("separator", "|");
|
|
261
|
+
const bar = `${theme.apply("muted", "[")}${theme.apply(token, contextBar(percent, width))}${theme.apply("muted", "]")}`;
|
|
262
|
+
const parts = [bar, `${theme.apply(token, `${Math.round(percent)}%`)}${theme.apply("muted", " used")}`];
|
|
263
|
+
const current = snapshot.context?.currentTokens;
|
|
264
|
+
const window = snapshot.context?.windowTokens;
|
|
265
|
+
if (current !== undefined && window !== undefined)
|
|
266
|
+
parts.push(theme.apply("muted", `${formatTokens(current)}/${formatTokens(window)}`));
|
|
258
267
|
return {
|
|
259
268
|
visible: true,
|
|
260
|
-
content:
|
|
269
|
+
content: parts.join(` ${pipe} `),
|
|
261
270
|
compactContent: theme.apply(token, `${Math.round(percent)}%`),
|
|
262
271
|
};
|
|
263
272
|
}),
|
|
@@ -362,10 +371,7 @@ function contextBarToken(percent: number): SemanticToken {
|
|
|
362
371
|
}
|
|
363
372
|
|
|
364
373
|
function formatTokens(value: number): string {
|
|
365
|
-
if (value >= 1_000_000) {
|
|
366
|
-
const millions = value / 1_000_000;
|
|
367
|
-
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
|
368
|
-
}
|
|
374
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
369
375
|
if (value >= 1_000) {
|
|
370
376
|
const thousands = value / 1_000;
|
|
371
377
|
return `${Number.isInteger(thousands) ? thousands : thousands.toFixed(1)}K`;
|
|
@@ -6,6 +6,7 @@ import { contextPercent, type StatusSnapshot, type ThinkingLevel } from "../../d
|
|
|
6
6
|
import type { ResolvedTheme } from "../../domain/theme.js";
|
|
7
7
|
import { resolveTheme } from "../../domain/theme.js";
|
|
8
8
|
import { stripAnsi, truncateAnsi } from "../../shared/ansi.js";
|
|
9
|
+
import { clipboardMarkerAtEnd, isSingleClipboardImagePath } from "../../shared/clipboard-path.js";
|
|
9
10
|
|
|
10
11
|
export const EDITOR_DIAGNOSTIC_KEY = "pi-style.editor";
|
|
11
12
|
type SetEditorComponent = NonNullable<ExtensionUIContext["setEditorComponent"]>;
|
|
@@ -30,6 +31,18 @@ export interface EditorInstallation {
|
|
|
30
31
|
dispose(): void;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** Structural clipboard-image paste surface (ADR 0009) consumed by the
|
|
35
|
+
* editor. Structural so this feature needs no cross-feature import; the
|
|
36
|
+
* app layer passes the instance built in features/messages. */
|
|
37
|
+
interface ClipboardImagePasteSurface {
|
|
38
|
+
enabled(): boolean;
|
|
39
|
+
clipboardHasImage(): boolean | null;
|
|
40
|
+
markerFromClipboard(): string;
|
|
41
|
+
markerFromArtifact(path: string): Promise<string>;
|
|
42
|
+
isMarkerIndexRegistered(index: number): boolean;
|
|
43
|
+
discardMarkerIndex(index: number): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
interface EditorOptions {
|
|
34
47
|
config: NormalizedPiStyleConfig;
|
|
35
48
|
snapshot: StatusSnapshot;
|
|
@@ -37,6 +50,22 @@ interface EditorOptions {
|
|
|
37
50
|
/** Full Pi theme for thinking-level border colors (optional; falls back to borderColor). */
|
|
38
51
|
fullTheme?: { getThinkingBorderColor?: (level: ThinkingLevel) => (str: string) => string };
|
|
39
52
|
onSnapshot: (snapshot: StatusSnapshot) => void;
|
|
53
|
+
/** Clipboard image paste surface (ADR 0009): instant `[Image #N] ` markers
|
|
54
|
+
* at keystroke time, artifact-path fallback, atomic marker backspace.
|
|
55
|
+
* Undefined keeps native behavior entirely. */
|
|
56
|
+
clipboardImagePaste?: ClipboardImagePasteSurface;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pi-tui Editor internals mirrored for O(1) atomic marker surgery. All are
|
|
60
|
+
* runtime-present on the Editor prototype chain but TS-private; the typed
|
|
61
|
+
* cast follows the autocompleteState-access pattern used in render(). */
|
|
62
|
+
interface EditorInternals {
|
|
63
|
+
readonly state: { lines: string[]; cursorLine: number; cursorCol: number };
|
|
64
|
+
lastAction: string | null;
|
|
65
|
+
readonly onChange?: (text: string) => void;
|
|
66
|
+
exitHistoryBrowsing(): void;
|
|
67
|
+
setCursorCol(col: number): void;
|
|
68
|
+
cancelAutocomplete(): void;
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
interface RenderPlan {
|
|
@@ -150,6 +179,10 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
150
179
|
private semanticDirty = false;
|
|
151
180
|
private disposed = false;
|
|
152
181
|
private renderPlanCache: { key: string; plan: RenderPlan } | undefined;
|
|
182
|
+
private readonly clipboardImagePaste: ClipboardImagePasteSurface | undefined;
|
|
183
|
+
/** Keybinding matcher (the factory's KeybindingsManager) for keystroke-level
|
|
184
|
+
* interception: the paste keybinding and backspace checks below. */
|
|
185
|
+
private readonly editorKeybindings: { matches(data: string, keybinding: string): boolean };
|
|
153
186
|
|
|
154
187
|
constructor(tui: Tui, theme: PiEditorTheme, keybindings: Keybindings, options: EditorOptions) {
|
|
155
188
|
super(tui, theme, keybindings);
|
|
@@ -158,6 +191,8 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
158
191
|
this.piTheme = theme;
|
|
159
192
|
this.fullTheme = options.fullTheme;
|
|
160
193
|
this.onSnapshot = options.onSnapshot;
|
|
194
|
+
this.clipboardImagePaste = options.clipboardImagePaste;
|
|
195
|
+
this.editorKeybindings = keybindings as unknown as { matches(data: string, keybinding: string): boolean };
|
|
161
196
|
this.semantic = semanticTheme(theme, options.config);
|
|
162
197
|
this.setPaddingX(0);
|
|
163
198
|
}
|
|
@@ -177,10 +212,100 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
177
212
|
|
|
178
213
|
override handleInput(data: string): void {
|
|
179
214
|
if (this.disposed) return;
|
|
215
|
+
if (this.handleClipboardImagePasteKeystroke(data)) return;
|
|
216
|
+
if (this.handleAtomicMarkerBackspace(data)) return;
|
|
180
217
|
super.handleInput(data);
|
|
181
218
|
this.onSnapshot({ ...this.snapshot });
|
|
182
219
|
}
|
|
183
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Instant marker (ADR 0009): when the built-in paste keybinding fires, the
|
|
223
|
+
* clipboard holds an image (sync native probe), and the surface is enabled,
|
|
224
|
+
* the keystroke is owned here — an `[Image #N] ` marker is inserted
|
|
225
|
+
* immediately (zero-latency feedback) and the bytes fill the registry
|
|
226
|
+
* entry asynchronously. Pi's own paste handler never runs for this
|
|
227
|
+
* keystroke, so no temp artifact is written. Text pastes (no image) and
|
|
228
|
+
* probe-unavailable hosts fall through to the native path untouched.
|
|
229
|
+
*/
|
|
230
|
+
private handleClipboardImagePasteKeystroke(data: string): boolean {
|
|
231
|
+
const surface = this.clipboardImagePaste;
|
|
232
|
+
if (!surface?.enabled()) return false;
|
|
233
|
+
if (!this.editorKeybindings.matches(data, "app.clipboard.pasteImage")) return false;
|
|
234
|
+
if (surface.clipboardHasImage() !== true) return false;
|
|
235
|
+
super.insertTextAtCursor(surface.markerFromClipboard());
|
|
236
|
+
this.onSnapshot({ ...this.snapshot });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Atomic marker backspace (ADR 0009): backspacing directly after a
|
|
242
|
+
* registered `[Image #N] ` marker deletes the whole marker as one unit —
|
|
243
|
+
* the image is discarded (image-paste semantics). Direct state surgery
|
|
244
|
+
* (O(1)) mirroring pi-tui's handleBackspace mutation protocol: no setText
|
|
245
|
+
* round-trip (it clears the pastes registry, and undo snapshots store that
|
|
246
|
+
* Map by reference — silently breaking `[paste #N]` atomicity on undo) and
|
|
247
|
+
* no per-column left-arrow inputs (O(K·L) through the keybinding chain and
|
|
248
|
+
* visual line maps). No undo snapshot is pushed: the discarded image
|
|
249
|
+
* cannot be restored, so the deletion is intentionally non-undoable.
|
|
250
|
+
*/
|
|
251
|
+
private handleAtomicMarkerBackspace(data: string): boolean {
|
|
252
|
+
const surface = this.clipboardImagePaste;
|
|
253
|
+
if (!surface?.enabled()) return false;
|
|
254
|
+
const isBackspace = this.editorKeybindings.matches(data, "tui.editor.deleteCharBackward") || data === "\x7f";
|
|
255
|
+
if (!isBackspace) return false;
|
|
256
|
+
const lines = this.getLines();
|
|
257
|
+
const cursor = this.getCursor();
|
|
258
|
+
const current = lines[cursor.line];
|
|
259
|
+
if (current === undefined) return false;
|
|
260
|
+
const before = current.slice(0, cursor.col);
|
|
261
|
+
const hit = clipboardMarkerAtEnd(before);
|
|
262
|
+
if (!hit || !surface.isMarkerIndexRegistered(hit.index)) return false;
|
|
263
|
+
const editor = this as unknown as EditorInternals;
|
|
264
|
+
const targetCol = cursor.col - hit.length;
|
|
265
|
+
editor.exitHistoryBrowsing();
|
|
266
|
+
editor.lastAction = null;
|
|
267
|
+
editor.cancelAutocomplete();
|
|
268
|
+
// Splice the marker out of its line in place (deletion never removes a
|
|
269
|
+
// line, so cursorLine is unchanged). setCursorCol also clears
|
|
270
|
+
// preferredVisualCol/snappedFromCursorCol. scrollOffset is left alone;
|
|
271
|
+
// render() re-clamps it to keep the cursor visible.
|
|
272
|
+
editor.state.lines[cursor.line] = current.slice(0, targetCol) + current.slice(cursor.col);
|
|
273
|
+
editor.setCursorCol(targetCol);
|
|
274
|
+
editor.onChange?.(this.getText());
|
|
275
|
+
surface.discardMarkerIndex(hit.index);
|
|
276
|
+
this.invalidate();
|
|
277
|
+
this.onSnapshot({ ...this.snapshot });
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Artifact-path fallback (ADR 0009): when the keystroke was not owned
|
|
283
|
+
* (probe unavailable, native editor path, config toggled) Pi's built-in
|
|
284
|
+
* paste still inserts a `<tmpdir>/pi-clipboard-<uuid>.<ext>` path through
|
|
285
|
+
* this method. Convert it to a marker when the artifact reads successfully;
|
|
286
|
+
* every failure inserts the original text verbatim (exact native behavior).
|
|
287
|
+
*/
|
|
288
|
+
override insertTextAtCursor(text: string): void {
|
|
289
|
+
const surface = this.clipboardImagePaste;
|
|
290
|
+
if (!surface?.enabled() || !isSingleClipboardImagePath(text)) {
|
|
291
|
+
super.insertTextAtCursor(text);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
void (async () => {
|
|
295
|
+
let replacement = text;
|
|
296
|
+
try {
|
|
297
|
+
replacement = await surface.markerFromArtifact(text);
|
|
298
|
+
} catch {
|
|
299
|
+
// Unreadable artifact or surface failure: keep native behavior.
|
|
300
|
+
}
|
|
301
|
+
super.insertTextAtCursor(replacement);
|
|
302
|
+
// The caller (Pi's handleClipboardPaste) requested its render before
|
|
303
|
+
// this async insert completed — without a repaint the marker stays
|
|
304
|
+
// invisible until the next keystroke. Request one now.
|
|
305
|
+
this.tui.requestRender();
|
|
306
|
+
})();
|
|
307
|
+
}
|
|
308
|
+
|
|
184
309
|
override invalidate(): void {
|
|
185
310
|
super.invalidate();
|
|
186
311
|
// The semantic theme is derived from (piTheme, config); piTheme is fixed per
|
|
@@ -412,6 +537,7 @@ export function installEditor(options: {
|
|
|
412
537
|
generation: number;
|
|
413
538
|
initialSnapshot: StatusSnapshot;
|
|
414
539
|
isCurrent?: () => boolean;
|
|
540
|
+
clipboardImagePaste?: EditorOptions["clipboardImagePaste"];
|
|
415
541
|
}): EditorInstallation | undefined {
|
|
416
542
|
if (!options.host.setEditorComponent) return undefined;
|
|
417
543
|
const previous = options.host.getEditorComponent?.();
|
|
@@ -436,6 +562,7 @@ export function installEditor(options: {
|
|
|
436
562
|
snapshot,
|
|
437
563
|
theme,
|
|
438
564
|
...(options.host.theme ? { fullTheme: options.host.theme } : {}),
|
|
565
|
+
...(options.clipboardImagePaste ? { clipboardImagePaste: options.clipboardImagePaste } : {}),
|
|
439
566
|
onSnapshot: (next) => {
|
|
440
567
|
if (!disposed && options.isCurrent?.() !== false) snapshot = next;
|
|
441
568
|
},
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Clipboard image input (ADR 0009): submit-side wiring.
|
|
2
|
+
//
|
|
3
|
+
// The editor surface (StyledEditor) owns the keystroke-time behavior:
|
|
4
|
+
// instant `[Image #N] ` markers via the shared pending registry. This module
|
|
5
|
+
// owns what happens at submit: resolve markers against the registry
|
|
6
|
+
// (one-shot; removed markers discard their images), then upgrade any raw
|
|
7
|
+
// clipboard artifact path tokens that bypassed the editor (native editor
|
|
8
|
+
// style, config toggled after paste, non-pi-style surfaces) to `[image]` +
|
|
9
|
+
// attachments. Fail-safe at every step: missing/oversized files keep their
|
|
10
|
+
// text, nothing is lost. Filesystem reads happen on the input path only —
|
|
11
|
+
// renderers stay I/O-free (TOOL-007).
|
|
12
|
+
|
|
13
|
+
import { readFile, stat } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { clipboardPathRegex, extractClipboardImageTokens as sharedExtractTokens } from "../../shared/clipboard-path.js";
|
|
16
|
+
import { clipboardHasImageSync, readClipboardImageBinary } from "../../shared/clipboard-presence.js";
|
|
17
|
+
import {
|
|
18
|
+
allocatePendingImage,
|
|
19
|
+
discardPendingMarkerIndex,
|
|
20
|
+
fillPendingImageBytes,
|
|
21
|
+
fillPendingImageFromPath,
|
|
22
|
+
isPendingImageFilled,
|
|
23
|
+
isPendingMarkerIndex,
|
|
24
|
+
type PendingImageAttachment,
|
|
25
|
+
resolvePendingImagesForSubmit as sharedResolve,
|
|
26
|
+
} from "../../shared/pending-images.js";
|
|
27
|
+
import { getMessagesRenderConfig } from "./render-config.js";
|
|
28
|
+
|
|
29
|
+
/** Replacement token for an attached clipboard image (marker vocabulary). */
|
|
30
|
+
export const IMAGE_TOKEN = "[image]";
|
|
31
|
+
|
|
32
|
+
/** Mirrors image-paste's guard; larger files keep the path. */
|
|
33
|
+
const MAX_ATTACHABLE_BYTES = 20 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
const MIME_BY_EXTENSION: Readonly<Record<string, string>> = Object.freeze({
|
|
36
|
+
png: "image/png",
|
|
37
|
+
jpg: "image/jpeg",
|
|
38
|
+
jpeg: "image/jpeg",
|
|
39
|
+
webp: "image/webp",
|
|
40
|
+
gif: "image/gif",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export { extractClipboardImageTokens, isSingleClipboardImagePath } from "../../shared/clipboard-path.js";
|
|
44
|
+
export {
|
|
45
|
+
type PendingImageAttachment,
|
|
46
|
+
resetPendingImageRegistry,
|
|
47
|
+
resolvePendingImagesForSubmit,
|
|
48
|
+
} from "../../shared/pending-images.js";
|
|
49
|
+
|
|
50
|
+
// ── Clipboard image paste surface (editor wiring; ADR 0009) ─────────────
|
|
51
|
+
|
|
52
|
+
/** Structural surface the editor consumes (kept structural so the editor
|
|
53
|
+
* feature needs no cross-feature import; app/runtime passes the instance). */
|
|
54
|
+
export interface ClipboardImagePasteSurface {
|
|
55
|
+
/** Config gate (`messages.clipboardImages`). */
|
|
56
|
+
enabled(): boolean;
|
|
57
|
+
/** Sync clipboard image presence; null when the probe is unavailable. */
|
|
58
|
+
clipboardHasImage(): boolean | null;
|
|
59
|
+
/** Instant marker: allocate + insert-time text; the bytes fill
|
|
60
|
+
* asynchronously from the clipboard. */
|
|
61
|
+
markerFromClipboard(): string;
|
|
62
|
+
/** Marker (or the original path on failure) from a clipboard artifact
|
|
63
|
+
* file — the fallback when the keystroke wasn't owned. */
|
|
64
|
+
markerFromArtifact(path: string): Promise<string>;
|
|
65
|
+
isMarkerIndexRegistered(index: number): boolean;
|
|
66
|
+
discardMarkerIndex(index: number): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test seam for the native clipboard probes. */
|
|
70
|
+
export interface ClipboardImagePasteDeps {
|
|
71
|
+
hasImage?: () => boolean | null;
|
|
72
|
+
readBinary?: () => Promise<{ bytes: Uint8Array } | null>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createClipboardImagePasteSurface(deps: ClipboardImagePasteDeps = {}): ClipboardImagePasteSurface {
|
|
76
|
+
const hasImage = deps.hasImage ?? clipboardHasImageSync;
|
|
77
|
+
// Owned-paste path: markerFromClipboard runs only after clipboardHasImage()
|
|
78
|
+
// returned true (editor keystroke handler), so the default binary read
|
|
79
|
+
// skips the redundant second native presence probe.
|
|
80
|
+
const readBinary = deps.readBinary ?? (() => readClipboardImageBinary({ skipPresenceProbe: true }));
|
|
81
|
+
return {
|
|
82
|
+
enabled: () => getMessagesRenderConfig().clipboardImages,
|
|
83
|
+
clipboardHasImage: () => {
|
|
84
|
+
try {
|
|
85
|
+
return hasImage();
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
markerFromClipboard() {
|
|
91
|
+
const { index, marker } = allocatePendingImage();
|
|
92
|
+
// Async fill: bytes land in the registry entry before submit resolves.
|
|
93
|
+
void (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const image = await readBinary();
|
|
96
|
+
if (image) {
|
|
97
|
+
// Zero-copy view (offset+length explicit so Uint8Array views over a
|
|
98
|
+
// larger backing buffer encode exactly their own range — avoids a
|
|
99
|
+
// second full copy of a ~1.3MB screenshot per paste).
|
|
100
|
+
fillPendingImageBytes(
|
|
101
|
+
index,
|
|
102
|
+
Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength).toString("base64"),
|
|
103
|
+
"image/png",
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Read failure below discards the entry.
|
|
109
|
+
}
|
|
110
|
+
// No bytes: discard so a submit never dangles on this entry (the
|
|
111
|
+
// marker stays plain text — documented degradation, nothing lost).
|
|
112
|
+
discardPendingMarkerIndex(index);
|
|
113
|
+
})();
|
|
114
|
+
return marker;
|
|
115
|
+
},
|
|
116
|
+
async markerFromArtifact(path: string) {
|
|
117
|
+
const { index, marker } = allocatePendingImage();
|
|
118
|
+
await fillPendingImageFromPath(index, path);
|
|
119
|
+
return isPendingImageFilled(index) ? marker : path;
|
|
120
|
+
},
|
|
121
|
+
isMarkerIndexRegistered: (index) => isPendingMarkerIndex(index),
|
|
122
|
+
discardMarkerIndex: (index) => discardPendingMarkerIndex(index),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve `[Image #N]` markers for a submit (delegates to the shared
|
|
128
|
+
* registry; one-shot per submit). Config-gated.
|
|
129
|
+
*/
|
|
130
|
+
export async function resolvePendingImageMarkers(
|
|
131
|
+
text: string,
|
|
132
|
+
): Promise<{ images: PendingImageAttachment[] } | undefined> {
|
|
133
|
+
if (!getMessagesRenderConfig().clipboardImages) return undefined;
|
|
134
|
+
return sharedResolve(text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Submit transform (raw artifact tokens) ─────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Input transform for raw clipboard-paste path tokens that bypassed the
|
|
141
|
+
* editor interception: read each token, attach the bytes as ImageContent,
|
|
142
|
+
* rewrite the token to `[image]`. Tokens whose file is missing/unreadable or
|
|
143
|
+
* over the size guard keep their original text. Returns undefined when
|
|
144
|
+
* nothing changed (caller keeps `action: "continue"`).
|
|
145
|
+
*/
|
|
146
|
+
export async function transformClipboardImages(
|
|
147
|
+
text: string,
|
|
148
|
+
deps: {
|
|
149
|
+
readFile?: (path: string) => Promise<Uint8Array>;
|
|
150
|
+
statSize?: (path: string) => Promise<number | undefined>;
|
|
151
|
+
tmpRoot?: string;
|
|
152
|
+
} = {},
|
|
153
|
+
): Promise<{ text: string; images: PendingImageAttachment[] } | undefined> {
|
|
154
|
+
if (!getMessagesRenderConfig().clipboardImages) return undefined;
|
|
155
|
+
const tokens = sharedExtractTokens(text, deps.tmpRoot);
|
|
156
|
+
if (tokens.length === 0) return undefined;
|
|
157
|
+
|
|
158
|
+
const attached: PendingImageAttachment[] = [];
|
|
159
|
+
const rewritten = new Map<string, string>();
|
|
160
|
+
for (const token of tokens) {
|
|
161
|
+
const image = await readAttachable(token, deps);
|
|
162
|
+
if (!image) continue;
|
|
163
|
+
attached.push({ type: "image", data: image.data, mimeType: image.mimeType });
|
|
164
|
+
rewritten.set(token, IMAGE_TOKEN);
|
|
165
|
+
}
|
|
166
|
+
if (attached.length === 0) return undefined;
|
|
167
|
+
return { text: rewriteTokens(text, rewritten, deps.tmpRoot), images: attached };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readAttachable(
|
|
171
|
+
path: string,
|
|
172
|
+
deps:
|
|
173
|
+
| { readFile?: (path: string) => Promise<Uint8Array>; statSize?: (path: string) => Promise<number | undefined> }
|
|
174
|
+
| undefined,
|
|
175
|
+
): Promise<{ data: string; mimeType: string } | undefined> {
|
|
176
|
+
const extension = path.split(".").pop() ?? "";
|
|
177
|
+
const mimeType = MIME_BY_EXTENSION[extension];
|
|
178
|
+
if (!mimeType) return undefined;
|
|
179
|
+
const readFileImpl = deps?.readFile ?? ((p: string) => readFile(p));
|
|
180
|
+
const statSizeImpl =
|
|
181
|
+
deps?.statSize ??
|
|
182
|
+
(async (p: string) => {
|
|
183
|
+
try {
|
|
184
|
+
return (await stat(p)).size;
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
const size = await statSizeImpl(path);
|
|
191
|
+
if (size === undefined || size <= 0 || size > MAX_ATTACHABLE_BYTES) return undefined;
|
|
192
|
+
const bytes = await readFileImpl(path);
|
|
193
|
+
if (bytes.length === 0 || bytes.length > MAX_ATTACHABLE_BYTES) return undefined;
|
|
194
|
+
return { data: Buffer.from(bytes).toString("base64"), mimeType };
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rewriteTokens(text: string, rewritten: Map<string, string>, tmpRoot?: string): string {
|
|
201
|
+
if (rewritten.size === 0) return text;
|
|
202
|
+
return text.replace(clipboardPathRegex(tmpRoot ?? tmpdir()), (match, token: string) =>
|
|
203
|
+
rewritten.has(token) ? IMAGE_TOKEN : match,
|
|
204
|
+
);
|
|
205
|
+
}
|