pi-ele-picker 0.5.0 → 0.6.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/README.md +3 -2
- package/client/picker.js +88 -25
- package/extensions/pi-picker.ts +61 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,10 +110,10 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
|
|
|
110
110
|
Vite reloads the page every time you save a file, so the composer is mirrored into `sessionStorage` and re-attached on the next load:
|
|
111
111
|
|
|
112
112
|
- The description and any attached screenshots come back exactly as they were.
|
|
113
|
+
- The captured element is **snapshotted at pick time and persisted with the draft**, so the element data survives the node being unmounted, an SPA route change, or the reload itself.
|
|
113
114
|
- The captured element is **re-resolved from its selector**, so after an HMR update you land straight back in the panel on the same element — no re-pick.
|
|
114
115
|
- Closing the panel with **✕** also suppresses the automatic reopen; the draft stays reachable through the launcher's hover.
|
|
115
|
-
-
|
|
116
|
-
- Sending is refused while the panel points at an element that has left the page, so a stale pick can never ship a description of nothing. A replaced node under the same selector (the usual HMR case) is picked up automatically instead.
|
|
116
|
+
- **Sending never depends on the element still being on the page.** Pick a button, go copy something from another route, then send — the description goes out with the element as it was when you picked it. A live read is used instead while you are still on the pick-time page, so an HMR swap of the same node is described in its current state; after a route change the snapshot wins, so a lookalike behind the same selector cannot be mistaken for your pick. When a snapshot was used, the status line says so.
|
|
117
117
|
- The draft is per tab and per path (`sessionStorage`, not `localStorage`), and one older than 24h is discarded. A successful send clears it.
|
|
118
118
|
- Screenshots that exceed the ~5MB `sessionStorage` quota are dropped from the draft with a notice rather than silently lost; the description is always kept.
|
|
119
119
|
|
|
@@ -153,6 +153,7 @@ The same payload is available from the bridge directly:
|
|
|
153
153
|
- Discovery is per-project: the bridge whose `cwd` matches `root` (and whose process is still alive) is selected, so several projects/sessions can run side by side without cross-talk.
|
|
154
154
|
- If pi restarts while a page is open (new random port + token), the next send re-discovers the bridge and retries once automatically — no page refresh needed. The status poll does the same: a stale port flips to `offline` for one cycle, then rediscovers.
|
|
155
155
|
- The bridge never auto-submits on its own: **写入pi输入框** only fills the editor; **直接发送** is an explicit user action in the page.
|
|
156
|
+
- Attached screenshots are decoded to temp files under `<tmpdir>/pi-picker` and referenced by path in the composed prompt (that is what the agent reads). Files older than 24h are swept on the next session start or pick, so the directory cannot grow without bound — nothing is meant to be kept there, and nothing needs cleaning up by hand.
|
|
156
157
|
|
|
157
158
|
## Development
|
|
158
159
|
|
package/client/picker.js
CHANGED
|
@@ -155,6 +155,12 @@
|
|
|
155
155
|
// The selector `target` was captured through. Kept so the draft can be
|
|
156
156
|
// re-attached after a reload replaces the DOM node `target` points at.
|
|
157
157
|
var targetSelector = null;
|
|
158
|
+
// Everything about the target captured at pick time. Sending must not depend on
|
|
159
|
+
// the node still being on the page — see elementPayload().
|
|
160
|
+
var targetSnapshot = null;
|
|
161
|
+
// The URL the target was picked on. A selector only identifies an element within
|
|
162
|
+
// one document: after a route change the same selector may hit a lookalike.
|
|
163
|
+
var targetUrl = null;
|
|
158
164
|
var shots = [];
|
|
159
165
|
// Set when a pick commits on pointerdown; swallows the follow-up click so the
|
|
160
166
|
// page (links, buttons) never reacts to the click that finished the pick.
|
|
@@ -491,12 +497,20 @@
|
|
|
491
497
|
}
|
|
492
498
|
}
|
|
493
499
|
|
|
494
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* Re-show the panel for the element already captured — no re-pick, no overwrite.
|
|
502
|
+
*
|
|
503
|
+
* A snapshot is enough to reopen: the developer may have navigated away from the
|
|
504
|
+
* page that element lives on, and losing access to their own draft over that
|
|
505
|
+
* would be the wrong trade. The highlight is simply skipped when there is
|
|
506
|
+
* nothing on screen to outline.
|
|
507
|
+
*/
|
|
495
508
|
function openPanel() {
|
|
496
509
|
var el = resolveTarget();
|
|
497
|
-
if (!el) return false;
|
|
510
|
+
if (!el && !targetSnapshot) return false;
|
|
498
511
|
panel.classList.add('open');
|
|
499
|
-
showHighlight(el);
|
|
512
|
+
if (el) showHighlight(el);
|
|
513
|
+
else hideHighlight();
|
|
500
514
|
positionPanel();
|
|
501
515
|
return true;
|
|
502
516
|
}
|
|
@@ -504,6 +518,8 @@
|
|
|
504
518
|
function select(el) {
|
|
505
519
|
target = el;
|
|
506
520
|
targetSelector = cssPath(el);
|
|
521
|
+
targetSnapshot = describe(el);
|
|
522
|
+
targetUrl = location.href;
|
|
507
523
|
// A new pick re-arms the draft: the user is working again, so a reload must
|
|
508
524
|
// bring the panel back even if they closed it earlier.
|
|
509
525
|
panelDismissed = false;
|
|
@@ -513,6 +529,14 @@
|
|
|
513
529
|
taEl.focus();
|
|
514
530
|
}
|
|
515
531
|
|
|
532
|
+
/**
|
|
533
|
+
* The element description handed to the bridge is chosen in send(), because the
|
|
534
|
+
* choice between a live read and the snapshot has to be reported to the user.
|
|
535
|
+
* Describing a detached node is never an option: `getComputedStyle()` and
|
|
536
|
+
* `getBoundingClientRect()` return nothing useful once it leaves the document, so
|
|
537
|
+
* the fallback has to be the captured value, not a fresh read.
|
|
538
|
+
*/
|
|
539
|
+
|
|
516
540
|
function vueLine(el) {
|
|
517
541
|
var v = vueInfo(el);
|
|
518
542
|
if (v.componentFile) return '<br>组件: <code>' + escapeHtml((v.componentName ? v.componentName + ' ' : '') + v.componentFile) + '</code>';
|
|
@@ -729,10 +753,15 @@
|
|
|
729
753
|
var rec = {
|
|
730
754
|
selector: targetSelector,
|
|
731
755
|
text: taEl.value,
|
|
756
|
+
// The element as it was when it was picked. Persisted because the reload
|
|
757
|
+
// that restores the draft is often the same one that removed the node —
|
|
758
|
+
// without this the send below could only go out with no element at all.
|
|
759
|
+
element: targetSnapshot,
|
|
760
|
+
url: targetUrl,
|
|
732
761
|
dismissed: panelDismissed,
|
|
733
762
|
savedAt: Date.now(),
|
|
734
763
|
};
|
|
735
|
-
if (!rec.text && !rec.selector) {
|
|
764
|
+
if (!rec.text && !rec.selector && !rec.element) {
|
|
736
765
|
// Nothing worth restoring — do not resurrect an empty panel on reload.
|
|
737
766
|
clearDraft();
|
|
738
767
|
return;
|
|
@@ -839,21 +868,26 @@
|
|
|
839
868
|
}
|
|
840
869
|
}
|
|
841
870
|
|
|
842
|
-
if (rec.selector)
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
871
|
+
if (rec.selector) targetSelector = String(rec.selector);
|
|
872
|
+
if (rec.url) targetUrl = String(rec.url);
|
|
873
|
+
if (rec.element && typeof rec.element === 'object') targetSnapshot = rec.element;
|
|
874
|
+
|
|
875
|
+
var el = targetSelector ? resolveTarget() : null;
|
|
876
|
+
if (el) {
|
|
877
|
+
metaEl.innerHTML = '<code>' + escapeHtml(targetSelector) + '</code>' + vueLine(el);
|
|
878
|
+
} else if (targetSnapshot) {
|
|
879
|
+
// Still sendable: the snapshot carries everything the bridge needs.
|
|
880
|
+
metaEl.innerHTML =
|
|
881
|
+
'<code>' + escapeHtml(targetSelector || '') + '</code><br>' +
|
|
882
|
+
'上次选中的元素已不在页面中,发送时会带上抓取时的快照';
|
|
847
883
|
}
|
|
848
884
|
|
|
849
885
|
if (rec.dismissed) return;
|
|
850
|
-
if (
|
|
851
|
-
openPanel();
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
886
|
+
if (openPanel()) return;
|
|
854
887
|
if (!taEl.value && !shots.length) return;
|
|
855
|
-
//
|
|
856
|
-
// visibly still there, and let the next pick attach
|
|
888
|
+
// No element at all (not even a snapshot) but there are words: show the
|
|
889
|
+
// composer so the draft is visibly still there, and let the next pick attach
|
|
890
|
+
// it to a new element.
|
|
857
891
|
panel.classList.add('open');
|
|
858
892
|
metaEl.textContent = '上次选中的元素已不在页面中,请重新点击选择';
|
|
859
893
|
positionPanel();
|
|
@@ -875,14 +909,16 @@
|
|
|
875
909
|
}
|
|
876
910
|
|
|
877
911
|
/** POST the payload to the bridge. Never rejects; status 0 means the fetch itself failed. */
|
|
878
|
-
function deliver(c, submit) {
|
|
912
|
+
function deliver(c, submit, element) {
|
|
879
913
|
var payload = {
|
|
880
914
|
text: taEl.value,
|
|
881
915
|
images: shots.map(function (d, i) {
|
|
882
916
|
return { name: 'shot-' + i + '.png', dataUrl: d };
|
|
883
917
|
}),
|
|
884
|
-
element:
|
|
885
|
-
|
|
918
|
+
element: element,
|
|
919
|
+
// The page the element lives on, which is not necessarily the page that is
|
|
920
|
+
// open right now.
|
|
921
|
+
url: targetUrl || location.href,
|
|
886
922
|
title: document.title,
|
|
887
923
|
submit: !!submit,
|
|
888
924
|
};
|
|
@@ -919,12 +955,32 @@
|
|
|
919
955
|
}
|
|
920
956
|
|
|
921
957
|
function send(submit) {
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
|
|
925
|
-
|
|
958
|
+
// Deliberately NOT gated on the element still being on the page. Picking an
|
|
959
|
+
// element and then wandering off to another route to copy something is a
|
|
960
|
+
// normal way to work, and blocking the send there throws away the words the
|
|
961
|
+
// developer typed.
|
|
962
|
+
//
|
|
963
|
+
// Two sources for the element, and the wrong one silently corrupts the payload:
|
|
964
|
+
// the snapshot captured at pick time (a plain value, so it survives an unmount,
|
|
965
|
+
// an SPA route change or a reload), and a live read of whatever the selector
|
|
966
|
+
// resolves to now. A live read wins only while we are still on the pick-time
|
|
967
|
+
// URL — an HMR swap of the same element is the case that needs it, while a
|
|
968
|
+
// route change can put an unrelated lookalike behind the same selector.
|
|
969
|
+
var live = resolveTarget();
|
|
970
|
+
var samePage = targetUrl === null || location.href === targetUrl;
|
|
971
|
+
var element = live && samePage ? describe(live) : targetSnapshot;
|
|
972
|
+
if (element) targetSnapshot = element;
|
|
973
|
+
|
|
974
|
+
if (!element && !taEl.value.trim()) {
|
|
975
|
+
setStatus('请先点击页面元素,或写下要改什么', 'err');
|
|
926
976
|
return;
|
|
927
977
|
}
|
|
978
|
+
|
|
979
|
+
// Only ever affects the status line, never whether the send happens.
|
|
980
|
+
var note = '';
|
|
981
|
+
if (element && !live) note = '(元素已不在页面中,已附抓取时的快照)';
|
|
982
|
+
else if (element && !samePage) note = '(页面已切换,已附抓取时的元素快照)';
|
|
983
|
+
|
|
928
984
|
sendBtn.disabled = true;
|
|
929
985
|
sendNowBtn.disabled = true;
|
|
930
986
|
setStatus(submit ? '直接发送中…' : '写入中…');
|
|
@@ -932,7 +988,7 @@
|
|
|
932
988
|
function attempt(forceFresh) {
|
|
933
989
|
return loadConfig(forceFresh).then(function (c) {
|
|
934
990
|
if (!c || !c.port) return { status: 0, body: null, noBridge: true };
|
|
935
|
-
return deliver(c, submit);
|
|
991
|
+
return deliver(c, submit, element);
|
|
936
992
|
});
|
|
937
993
|
}
|
|
938
994
|
|
|
@@ -950,10 +1006,17 @@
|
|
|
950
1006
|
return;
|
|
951
1007
|
}
|
|
952
1008
|
if (out.body && out.body.ok) {
|
|
1009
|
+
// Say which element went out, so a stale pick is visible rather than silent.
|
|
953
1010
|
if (out.body.submitted) {
|
|
954
|
-
setStatus(
|
|
1011
|
+
setStatus(
|
|
1012
|
+
(out.body.queued ? '已发送 ✓(agent 忙碌,已排队为后续任务)' : '已直接发送给 pi 并开始执行 ✓') + note,
|
|
1013
|
+
'ok'
|
|
1014
|
+
);
|
|
955
1015
|
} else {
|
|
956
|
-
setStatus(
|
|
1016
|
+
setStatus(
|
|
1017
|
+
(out.body.inserted ? '已写入 pi 输入框 ✓(去终端按回车发送)' : '已发送给 pi ✓') + note,
|
|
1018
|
+
'ok'
|
|
1019
|
+
);
|
|
957
1020
|
}
|
|
958
1021
|
taEl.value = '';
|
|
959
1022
|
shots = [];
|
package/extensions/pi-picker.ts
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { createServer, type Server } from 'node:http';
|
|
38
38
|
import { randomBytes } from 'node:crypto';
|
|
39
|
-
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
39
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
40
40
|
import { join } from 'node:path';
|
|
41
41
|
import { homedir, tmpdir } from 'node:os';
|
|
42
42
|
import type { ExtensionAPI, ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -45,6 +45,24 @@ const REG_DIR = join(homedir(), '.pi', 'agent', 'pi-picker');
|
|
|
45
45
|
const IMG_DIR = join(tmpdir(), 'pi-picker');
|
|
46
46
|
const MAX_BODY = 40 * 1024 * 1024;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* How long a staged screenshot is kept.
|
|
50
|
+
*
|
|
51
|
+
* The file only has to outlive the moment the agent reads it, and the agent reads
|
|
52
|
+
* it after the developer sends the prompt — which is not always right away: the
|
|
53
|
+
* "写入pi输入框" flow can leave a prompt sitting in the editor until the next
|
|
54
|
+
* morning. A day covers that, and it matches the browser draft's own TTL: once the
|
|
55
|
+
* draft has expired those paths can never be referenced again.
|
|
56
|
+
*
|
|
57
|
+
* Nothing else ever deletes these files, so without the sweep below the directory
|
|
58
|
+
* grows forever — one screenshot is a few MB, and every pick leaves its own here.
|
|
59
|
+
*/
|
|
60
|
+
const IMG_TTL_MS = 24 * 60 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
/** The sweep is cheap but pointless per pick, so it runs at most this often. */
|
|
63
|
+
const IMG_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
64
|
+
let lastImageSweepAt = 0;
|
|
65
|
+
|
|
48
66
|
function log(msg: string): void {
|
|
49
67
|
try {
|
|
50
68
|
process.stderr.write(`[pi-picker] ${msg}\n`);
|
|
@@ -203,8 +221,49 @@ interface PickPayload {
|
|
|
203
221
|
submit?: boolean;
|
|
204
222
|
}
|
|
205
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Delete staged screenshots older than IMG_TTL_MS.
|
|
226
|
+
*
|
|
227
|
+
* Age comes from the file's mtime rather than from any index we keep, so images
|
|
228
|
+
* orphaned by a pi process that died mid-pick are collected too.
|
|
229
|
+
*
|
|
230
|
+
* Best-effort throughout, and `force: true` on removal: a second pi process
|
|
231
|
+
* sweeping the same shared tmpdir at the same moment must not be able to fail a
|
|
232
|
+
* pick, and a file that is already gone is the outcome we wanted anyway.
|
|
233
|
+
*
|
|
234
|
+
* `force` bypasses the throttle — used on session start, where one sweep per
|
|
235
|
+
* session is exactly the guarantee we want.
|
|
236
|
+
*/
|
|
237
|
+
function sweepStaleImages(force = false): void {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
if (!force && now - lastImageSweepAt < IMG_SWEEP_INTERVAL_MS) return;
|
|
240
|
+
lastImageSweepAt = now;
|
|
241
|
+
|
|
242
|
+
let removed = 0;
|
|
243
|
+
try {
|
|
244
|
+
for (const entry of readdirSync(IMG_DIR, { withFileTypes: true })) {
|
|
245
|
+
// Only ever touch regular files we could have written ourselves.
|
|
246
|
+
if (!entry.isFile()) continue;
|
|
247
|
+
const full = join(IMG_DIR, entry.name);
|
|
248
|
+
try {
|
|
249
|
+
if (now - statSync(full).mtimeMs <= IMG_TTL_MS) continue;
|
|
250
|
+
rmSync(full, { force: true });
|
|
251
|
+
removed++;
|
|
252
|
+
} catch {
|
|
253
|
+
/* vanished under us, or unreadable — nothing left to do for this one */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
return; // IMG_DIR does not exist yet: nothing has ever been staged
|
|
258
|
+
}
|
|
259
|
+
if (removed) log(`swept ${removed} expired screenshot(s) from ${IMG_DIR}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
206
262
|
function saveImages(images: PickImage[] | undefined): string[] {
|
|
207
263
|
const paths: string[] = [];
|
|
264
|
+
// Throttled, so a long-running session does not accumulate a week of screenshots
|
|
265
|
+
// behind it; session start forces a sweep, so a restart always collects.
|
|
266
|
+
sweepStaleImages();
|
|
208
267
|
(images ?? []).forEach((img, i) => {
|
|
209
268
|
const m = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(img?.dataUrl ?? '');
|
|
210
269
|
if (!m) return;
|
|
@@ -467,6 +526,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
467
526
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
468
527
|
mkdirSync(REG_DIR, { recursive: true });
|
|
469
528
|
sweepStaleRegistry();
|
|
529
|
+
sweepStaleImages(true);
|
|
470
530
|
regFile = join(REG_DIR, `${process.pid}.json`);
|
|
471
531
|
writeFileSync(
|
|
472
532
|
regFile,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ele-picker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Pick an element in your running page, describe the change, and send it to a pi session. Ships a Vite dev plugin (browser picker + discovery endpoint) and the matching pi bridge extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|