pi-studio 0.9.63 → 0.10.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.
Files changed (53) hide show
  1. package/BUFFER-DESIGN.md +197 -0
  2. package/CHANGELOG.md +31 -0
  3. package/PRERELEASE-CHECKLIST.md +100 -0
  4. package/README.md +110 -193
  5. package/ROADMAP.md +63 -6
  6. package/WORKFLOW.md +32 -2
  7. package/assets/screenshots/dark-workspace.png +0 -0
  8. package/assets/screenshots/light-workspace.png +0 -0
  9. package/assets/screenshots/theme-zen-code.png +0 -0
  10. package/client/studio-client.js +6694 -942
  11. package/client/studio-editor-draft-helpers.js +162 -0
  12. package/client/studio-navigation-helpers.js +31 -5
  13. package/client/studio.css +460 -12
  14. package/index.ts +1178 -79
  15. package/package.json +5 -3
  16. package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
  17. package/shared/studio-buffer-client.js +663 -0
  18. package/shared/studio-buffer-decisions.js +129 -0
  19. package/shared/studio-buffer-lineage.js +48 -0
  20. package/shared/studio-buffer-recovery-panel.js +132 -0
  21. package/shared/studio-buffer-recovery.js +181 -0
  22. package/shared/studio-buffer-server.js +700 -0
  23. package/shared/studio-buffer-store.js +227 -0
  24. package/shared/studio-buffer-switching.js +53 -0
  25. package/shared/studio-disk-revisions.js +15 -0
  26. package/shared/studio-document-entrypoints.js +38 -0
  27. package/shared/studio-document-escrow.js +147 -0
  28. package/shared/studio-document-file-claims.js +215 -0
  29. package/shared/studio-document-host-client.js +268 -0
  30. package/shared/studio-document-hosting.js +319 -0
  31. package/shared/studio-document-launch-client.js +153 -0
  32. package/shared/studio-document-open-client.js +96 -0
  33. package/shared/studio-document-open-storage.js +21 -0
  34. package/shared/studio-document-paths.js +29 -0
  35. package/shared/studio-editor-mode.js +48 -0
  36. package/shared/studio-linked-reader-ui.js +232 -0
  37. package/shared/studio-linked-reader.js +110 -0
  38. package/shared/studio-metadata-write-order.js +35 -0
  39. package/shared/studio-read-only-media.js +24 -0
  40. package/shared/studio-resource-grants.js +3 -0
  41. package/shared/studio-run-lifecycle.js +47 -0
  42. package/shared/studio-system-theme.js +9 -0
  43. package/assets/screenshots/dark-code-mode.png +0 -0
  44. package/assets/screenshots/dark-critique.png +0 -0
  45. package/assets/screenshots/dark-fenced-code.png +0 -0
  46. package/assets/screenshots/dark-focus-mode.png +0 -0
  47. package/assets/screenshots/dark-julia-fenced.png +0 -0
  48. package/assets/screenshots/dark-math.png +0 -0
  49. package/assets/screenshots/dark-mermaid.png +0 -0
  50. package/assets/screenshots/dark-zen.png +0 -0
  51. package/assets/screenshots/light-code-mode.png +0 -0
  52. package/assets/screenshots/light-fenced-code.png +0 -0
  53. package/assets/screenshots/light-math.png +0 -0
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ // Oliver, 13 Oct: dual editor mode (Prompt and Document) is the default; single editor
5
+ // mode is the earlier one-editor Studio. The choice is kept per Pi profile. The older
6
+ // development flags, when any is set, still decide everything, as before.
7
+ export const STUDIO_EDITOR_MODE_ENV = ["PI_STUDIO_BUFFER_RECOVERY", "PI_STUDIO_BUFFER_SWITCHING", "PI_STUDIO_DOCUMENT_HOSTING"];
8
+
9
+ export function resolveStudioEditorFlags(env, savedMode) {
10
+ const forced = STUDIO_EDITOR_MODE_ENV.some(key => typeof env[key] === "string" && env[key] !== "");
11
+ const recovery = forced ? env.PI_STUDIO_BUFFER_RECOVERY === "1" : savedMode !== "single";
12
+ const switching = recovery && (!forced || env.PI_STUDIO_BUFFER_SWITCHING === "1");
13
+ const hosting = switching && (!forced || env.PI_STUDIO_DOCUMENT_HOSTING === "1");
14
+ return { forced, recovery, switching, hosting, mode: hosting ? "dual" : "single" };
15
+ }
16
+
17
+ export function readStudioEditorMode(path) {
18
+ try {
19
+ const mode = JSON.parse(readFileSync(path, "utf8"))?.mode;
20
+ return mode === "single" || mode === "dual" ? mode : null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ export function writeStudioEditorMode(path, mode) {
25
+ if (mode !== "single" && mode !== "dual") throw new Error("Unknown editor mode.");
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ const temporary = path + "." + process.pid + ".tmp";
28
+ writeFileSync(temporary, JSON.stringify({ mode }) + "\n", "utf8");
29
+ renameSync(temporary, path);
30
+ }
31
+
32
+ // Starting, stopping and switching Studio take turns (Sol, candidate69). run() queues at
33
+ // once, so a caller that checked pending() and then queued can't be overtaken.
34
+ export function createStudioTurns() {
35
+ /** @type {Promise<unknown>} */
36
+ let tail = Promise.resolve();
37
+ let pending = 0;
38
+ return Object.freeze({
39
+ /** @template T @param {() => Promise<T>} operation @returns {Promise<T>} */
40
+ run(operation) {
41
+ pending += 1;
42
+ const turn = tail.then(operation).finally(() => { pending -= 1; });
43
+ tail = turn.catch(() => undefined);
44
+ return turn;
45
+ },
46
+ pending: () => pending,
47
+ });
48
+ }
@@ -0,0 +1,232 @@
1
+ import { createStudioLinkedReader } from "./studio-linked-reader.js";
2
+
3
+ // A reading surface, not an editor or a second owner of the underlying buffers.
4
+ // Application callbacks keep resource grants and guarded editing in one place.
5
+ export function createStudioLinkedReaderUi(options) {
6
+ const { document, window, host, root, footer, select, header, metadata } = options;
7
+ const history = createStudioLinkedReader();
8
+ let disposed = false, owner = null, ownerPage = null, renderSequence = 0;
9
+ const make = (tag, name, text) => {
10
+ const element = document.createElement(tag); element.className = name;
11
+ if (text) element.textContent = text;
12
+ return element;
13
+ };
14
+ const toolbar = make("div", "studio-reader-toolbar");
15
+ toolbar.hidden = true; toolbar.setAttribute("aria-label", "Linked file navigation");
16
+ const surface = make("div", "studio-reader-surface"); surface.hidden = true;
17
+ const content = make("div", "panel-scroll rendered-markdown studio-reader-content");
18
+ content.id = "studioLinkedReaderContent"; content.setAttribute("tabindex", "0");
19
+ surface.append(content); host.insertBefore(toolbar, metadata || root); host.append(surface);
20
+ const button = (label, fn, group = toolbar) => {
21
+ const element = make("button", "studio-reader-action", label); element.type = "button";
22
+ element.addEventListener("click", () => { void fn(); }); group.append(element); return element;
23
+ };
24
+ const backButton = button("Back", back), forwardButton = button("Forward", forward);
25
+ const snapshotLabel = make("span", "studio-reader-snapshot", ""); toolbar.append(snapshotLabel);
26
+ const refreshButton = button("Refresh", refresh);
27
+ refreshButton.title = "Read this file again.";
28
+ const actions = make("div", "studio-reader-actions"); toolbar.append(actions);
29
+ const editButton = button("Checking editor…", () => openForEdit(), actions);
30
+ // Matches the Files row (Oliver, 10 Oct): a file open nowhere yet can go to either buffer.
31
+ const promptButton = button("Open in Prompt", () => openForEdit("prompt"), actions);
32
+ const returnButton = button("Return", () => close({ restore: true }), actions);
33
+ const temporaryOption = make("option", "", "");
34
+ temporaryOption.value = ""; temporaryOption.disabled = true; temporaryOption.dataset.linkedReaderOption = "1";
35
+ const previousRootInert = Boolean(root.inert), previousFooterInert = Boolean(footer?.inert), previousMetadataInert = Boolean(metadata?.inert);
36
+ const warn = error => options.report?.(error?.message || String(error || "Could not read this file."));
37
+ const active = () => history.snapshot().active;
38
+ const current = page => !disposed && history.isCurrent(page);
39
+ const context = () => {
40
+ const page = history.snapshot().current;
41
+ return page ? { sourcePath: page.path, resourceDir: page.resourceDir, isCurrent: () => current(page) } : null;
42
+ };
43
+ function layout() {
44
+ if (disposed || !active()) return;
45
+ const pane = host.getBoundingClientRect(), heading = (header || root).getBoundingClientRect();
46
+ toolbar.style.top = Math.max(0, (header ? heading.bottom : heading.top) - pane.top - (host.clientTop || 0)) + "px";
47
+ const bar = toolbar.getBoundingClientRect();
48
+ surface.style.top = Math.max(0, bar.bottom - pane.top - (host.clientTop || 0)) + "px";
49
+ }
50
+ function displayLabel(page, state) {
51
+ const parts = path => String(path || "").replace(/\\/g, "/").split("/").filter(Boolean);
52
+ const pathParts = parts(page.path), name = pathParts.at(-1) || page.label;
53
+ const matches = state.entries.filter(entry => parts(entry.path).at(-1) === name);
54
+ const originMatches = parts(state.origin?.path).at(-1) === name;
55
+ if (matches.length < 2 && !originMatches) return name;
56
+ const others = [...new Set([...matches.map(entry => entry.path), ...(originMatches ? [state.origin.path] : [])])].filter(path => path !== page.path);
57
+ for (let count = 2; count <= pathParts.length; count++) {
58
+ const label = pathParts.slice(-count).join("/");
59
+ if (!others.some(path => parts(path).slice(-count).join("/") === label)) return label;
60
+ }
61
+ return page.path;
62
+ }
63
+ function sync() {
64
+ if (disposed) return;
65
+ const state = history.snapshot(), reading = state.active, page = state.current;
66
+ host.dataset.linkedReaderActive = reading ? "1" : "0";
67
+ toolbar.hidden = !state.entries.length;
68
+ surface.hidden = !reading;
69
+ root.inert = reading || previousRootInert;
70
+ if (footer) footer.inert = reading || previousFooterInert;
71
+ if (metadata) metadata.inert = reading || previousMetadataInert;
72
+ if (reading) {
73
+ root.setAttribute("aria-hidden", "true"); footer?.setAttribute("aria-hidden", "true"); metadata?.setAttribute("aria-hidden", "true");
74
+ if (temporaryOption.parentElement !== select) select.append(temporaryOption);
75
+ temporaryOption.textContent = displayLabel(page, state); select.value = "";
76
+ select.title = page.path + " · Read-only snapshot.";
77
+ const date = new Date(page.readAt);
78
+ snapshotLabel.textContent = "Read-only · saved file as of " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
79
+ snapshotLabel.title = "Saved file read at " + date.toLocaleString() + ". Refresh to read it again.";
80
+ } else {
81
+ root.removeAttribute("aria-hidden"); footer?.removeAttribute("aria-hidden"); metadata?.removeAttribute("aria-hidden"); temporaryOption.remove();
82
+ toolbar.style.top = "";
83
+ }
84
+ const rootLabel = options.originLabel(state.origin) || "current view";
85
+ const rootTitle = options.originTitle?.(state.origin) || rootLabel;
86
+ backButton.textContent = "← " + (state.back ? displayLabel(state.back, state) : rootLabel);
87
+ backButton.title = state.back?.path || rootTitle;
88
+ backButton.hidden = !reading; backButton.disabled = !reading;
89
+ forwardButton.textContent = (state.forward ? displayLabel(state.forward, state) : "Forward") + " →";
90
+ forwardButton.title = state.forward?.path || "";
91
+ forwardButton.hidden = !state.forward; forwardButton.disabled = !state.forward;
92
+ refreshButton.hidden = !reading; refreshButton.disabled = !reading || Boolean(state.pending);
93
+ editButton.hidden = !reading;
94
+ editButton.textContent = ownerPage === page?.id && owner ? owner.label : "Checking editor…";
95
+ editButton.disabled = !reading || Boolean(state.pending) || ownerPage !== page?.id || !owner || owner.disabled === true || options.editingBusy?.() === true;
96
+ promptButton.hidden = !reading || ownerPage !== page?.id || owner?.kind !== "new" || options.canOpenInPrompt?.() !== true;
97
+ promptButton.disabled = editButton.disabled;
98
+ returnButton.textContent = reading ? "Return to " + rootLabel : "Close";
99
+ returnButton.title = reading ? "Leave this reading history for " + rootTitle + "." : "Forget this reading history.";
100
+ snapshotLabel.hidden = !reading; layout();
101
+ }
102
+ const changed = () => { sync(); options.changed?.(); };
103
+ function rememberPosition() {
104
+ if (active() && !content.inert) history.rememberPosition({ top: content.scrollTop, left: content.scrollLeft });
105
+ }
106
+ content.addEventListener("scroll", rememberPosition);
107
+ function signature(value) {
108
+ return value ? JSON.stringify([value.kind, value.label || "", value.workspaceId || "", value.bufferId || "", value.disabled === true]) : "";
109
+ }
110
+ async function checkOwner(page) {
111
+ try {
112
+ const result = await options.findOwner(page);
113
+ if (!current(page)) return;
114
+ owner = result; ownerPage = page.id; sync();
115
+ } catch (error) {
116
+ if (!current(page)) return;
117
+ owner = { kind: "unavailable", label: "Editor unavailable", disabled: true }; ownerPage = page.id;
118
+ sync(); warn(error);
119
+ }
120
+ }
121
+ async function renderPage() {
122
+ const page = history.snapshot().current;
123
+ if (!page || disposed) return;
124
+ const sequence = ++renderSequence;
125
+ owner = null; ownerPage = null;
126
+ content.inert = true; content.replaceChildren(); content.scrollTop = 0; content.scrollLeft = 0;
127
+ const loading = make("div", "preview-loading", "Reading " + page.label + "…"); content.append(loading);
128
+ changed();
129
+ const stillCurrent = () => sequence === renderSequence && current(page);
130
+ const ownerCheck = checkOwner(page);
131
+ try {
132
+ await options.render(content, page, stillCurrent);
133
+ if (!stillCurrent()) return;
134
+ content.inert = false;
135
+ const saved = history.snapshot().current.position;
136
+ content.scrollTop = saved.top; content.scrollLeft = saved.left;
137
+ if (page.fragment && saved.top === 0) {
138
+ let fragment = page.fragment; try { fragment = decodeURIComponent(fragment); } catch {}
139
+ const heading = Array.from(content.querySelectorAll?.("[id]") || []).find(el => el.id === fragment);
140
+ // Do not scroll the page/editor or move focus while reading.
141
+ if (heading) content.scrollTop = Math.max(0, heading.getBoundingClientRect().top - content.getBoundingClientRect().top + content.scrollTop);
142
+ }
143
+ } catch (error) { if (stillCurrent()) { content.inert = false; warn(error); } }
144
+ await ownerCheck;
145
+ if (stillCurrent()) sync();
146
+ }
147
+ async function read(href, suppliedContext, settings = {}) {
148
+ if (disposed || !options.contextIsCurrent(suppliedContext)) return false;
149
+ rememberPosition();
150
+ const origin = options.captureOrigin(suppliedContext);
151
+ const ticket = history.begin(origin, settings);
152
+ if (!ticket) return false;
153
+ const isCurrent = () => !disposed && history.snapshot().pending === ticket && options.contextIsCurrent(suppliedContext);
154
+ const requestContext = { ...(suppliedContext || {}), isCurrent };
155
+ changed();
156
+ try {
157
+ const payload = await options.read(href, requestContext);
158
+ if (!isCurrent()) { history.cancel(ticket); return false; }
159
+ const fragment = String(href || "").split("#").slice(1).join("#");
160
+ const result = history.commit(ticket, { ...payload, fragment });
161
+ if (!result.ok) { warn(result.message); return false; }
162
+ await renderPage(); return true;
163
+ } catch (error) {
164
+ const owned = history.snapshot().pending === ticket;
165
+ history.cancel(ticket);
166
+ if (owned && !error?.studioCancelled && options.contextIsCurrent(suppliedContext) && !disposed) warn(error);
167
+ return false;
168
+ } finally { changed(); }
169
+ }
170
+ async function refresh() {
171
+ const page = history.snapshot().current;
172
+ if (!page) return false;
173
+ // page.path is literal; read() takes a link, so keep "#", "?" and "%" in the name.
174
+ return read(String(page.path).replace(/[%#?]/g, ch => encodeURIComponent(ch)), context(), { refresh: true });
175
+ }
176
+ async function back() {
177
+ rememberPosition();
178
+ const result = history.back();
179
+ if (!result.ok) return false;
180
+ if (result.left) { renderSequence++; changed(); await options.returnToOrigin(result.origin); sync(); }
181
+ else await renderPage();
182
+ return true;
183
+ }
184
+ async function forward() {
185
+ rememberPosition();
186
+ if (!history.forward().ok) return false;
187
+ await renderPage(); return true;
188
+ }
189
+ async function close(settings = {}) {
190
+ rememberPosition();
191
+ const wasActive = active(), origin = history.close(); renderSequence++;
192
+ owner = null; ownerPage = null; content.replaceChildren(); changed();
193
+ if (wasActive) {
194
+ if (settings.restore && origin) await options.returnToOrigin(origin);
195
+ else if (settings.manual === true) options.manualLeave();
196
+ }
197
+ sync();
198
+ }
199
+ async function openForEdit(role = "document") {
200
+ const page = history.snapshot().current, displayed = owner;
201
+ if (!page || !displayed || displayed.disabled || history.snapshot().pending || options.editingBusy?.()) return false;
202
+ try {
203
+ const checked = await options.findOwner(page);
204
+ if (!current(page)) return false;
205
+ if (signature(checked) !== signature(displayed)) {
206
+ owner = checked; ownerPage = page.id; sync();
207
+ warn("The editing destination changed. Check the action and choose it again."); return false;
208
+ }
209
+ const opened = await options.openForEdit(page, checked, () => current(page), role);
210
+ if (opened && current(page)) await close({ restore: false, manual: true });
211
+ return Boolean(opened);
212
+ } catch (error) { if (current(page)) warn(error); return false; }
213
+ }
214
+ const observer = typeof window.ResizeObserver === "function" ? new window.ResizeObserver(layout) : null;
215
+ observer?.observe(host); observer?.observe(toolbar); if (header) observer?.observe(header);
216
+ window.addEventListener("resize", layout);
217
+ function revealFragment(fragment) {
218
+ if (!active() || content.inert) return false;
219
+ let id = fragment; try { id = decodeURIComponent(id); } catch {}
220
+ const target = Array.from(content.querySelectorAll?.("[id]") || []).find(el => el.id === id);
221
+ if (!target) return false;
222
+ content.scrollTop = Math.max(0, target.getBoundingClientRect().top - content.getBoundingClientRect().top + content.scrollTop);
223
+ rememberPosition(); return true;
224
+ }
225
+ function dispose() {
226
+ if (disposed) return;
227
+ void close({ restore: false }); disposed = true; observer?.disconnect();
228
+ window.removeEventListener("resize", layout); toolbar.remove(); surface.remove(); temporaryOption.remove();
229
+ }
230
+ return Object.freeze({ read, refresh, back, forward, close, openForEdit, sync, context, revealFragment, dispose,
231
+ snapshot: () => history.snapshot(), isCurrent: current, contentElement: content });
232
+ }
@@ -0,0 +1,110 @@
1
+ // Bounded, page-local reading history. These snapshots are never editable
2
+ // buffers, filesystem grants, save receipts or recovered workspace authority.
3
+ export function createStudioLinkedReader(options = {}) {
4
+ const limit = (value, fallback, maximum) => Number.isSafeInteger(value) && value > 0
5
+ ? Math.min(value, maximum) : fallback;
6
+ const maxEntries = limit(options.maxEntries, 24, 100);
7
+ const maxCharacters = limit(options.maxCharacters, 2700000, 2700000);
8
+ const maxEntryCharacters = limit(options.maxEntryCharacters, 900000, 900000);
9
+ const now = typeof options.now === "function" ? options.now : Date.now;
10
+ let entries = [], index = -1, origin = null, pending = null, sequence = 0, nextId = 0;
11
+ const current = () => index >= 0 ? entries[index] : null;
12
+ const position = value => Object.freeze({
13
+ top: Number.isFinite(value?.top) ? Math.max(0, Math.min(100000000, value.top)) : 0,
14
+ left: Number.isFinite(value?.left) ? Math.max(0, Math.min(100000000, value.left)) : 0,
15
+ });
16
+ const validString = (value, maximum) => typeof value === "string" && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
17
+ const originCopy = value => value && ["editor", "response", "files", "view"].includes(value.kind)
18
+ ? Object.freeze({ ...value }) : null;
19
+ const invalidate = () => { pending = null; sequence++; };
20
+ const owns = ticket => Boolean(ticket && ticket === pending && ticket.sequence === sequence);
21
+ const failure = message => ({ ok: false, message });
22
+
23
+ function begin(nextOrigin, settings = {}) {
24
+ const refresh = settings.refresh === true;
25
+ if (refresh && !current()) return null;
26
+ // A visit while reading always belongs to that reading session's root,
27
+ // irrespective of changes to the editor or hidden response beneath it.
28
+ const root = current() || refresh ? origin : originCopy(nextOrigin) || origin;
29
+ if (!root) return null;
30
+ invalidate();
31
+ pending = Object.freeze({ sequence, refresh, origin: root, targetId: current()?.id ?? null,
32
+ newRoot: !current() && root !== origin });
33
+ return pending;
34
+ }
35
+
36
+ function commit(ticket, value) {
37
+ if (!owns(ticket)) return failure("This reading request is no longer current.");
38
+ pending = null;
39
+ if (!value || !validString(value.path, 4096) || !value.path
40
+ || !/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value.path)
41
+ || typeof value.text !== "string") return failure("The file snapshot could not be prepared.");
42
+ if (value.text.length > maxEntryCharacters) return failure("This file exceeds the reader's text limit.");
43
+ const selected = current();
44
+ if (ticket.refresh && (!selected || selected.id !== ticket.targetId || selected.path !== value.path)) {
45
+ return failure("The file identity changed while refreshing. Kept the current snapshot.");
46
+ }
47
+ const trail = ticket.newRoot ? [] : ticket.refresh ? entries.slice() : entries.slice(0, index + 1);
48
+ const nextCount = trail.length + (ticket.refresh ? 0 : 1);
49
+ const characters = trail.reduce((total, item) => total + item.text.length, 0)
50
+ - (ticket.refresh ? selected.text.length : 0) + value.text.length;
51
+ if (nextCount > maxEntries || characters > maxCharacters) {
52
+ return failure("Reader history is full. Kept the current snapshots; close the reader to start a new history.");
53
+ }
54
+ const name = value.path.replace(/\\/g, "/").split("/").pop() || "file";
55
+ const item = Object.freeze({
56
+ id: ticket.refresh ? selected.id : ++nextId,
57
+ revision: ticket.refresh ? selected.revision + 1 : 1,
58
+ path: value.path,
59
+ label: validString(value.label, 512) && value.label ? value.label : name,
60
+ text: value.text,
61
+ resourceDir: validString(value.resourceDir, 4096) ? value.resourceDir : "",
62
+ extension: validString(value.extension, 32) ? value.extension : "",
63
+ fragment: ticket.refresh ? selected.fragment : validString(value.fragment, 1024) ? value.fragment : "",
64
+ position: ticket.refresh ? selected.position : position(null),
65
+ readAt: now(),
66
+ });
67
+ if (ticket.refresh) trail[index] = item;
68
+ else { trail.push(item); index = trail.length - 1; }
69
+ entries = trail;
70
+ origin = ticket.origin;
71
+ return { ok: true, current: item };
72
+ }
73
+
74
+ function cancel(ticket) {
75
+ if (!owns(ticket)) return false;
76
+ invalidate(); return true;
77
+ }
78
+ function rememberPosition(value) {
79
+ const selected = current();
80
+ if (!selected) return false;
81
+ entries[index] = Object.freeze({ ...selected, position: position(value) });
82
+ return true;
83
+ }
84
+ function back() {
85
+ if (!current()) return failure("There is no earlier reading location.");
86
+ invalidate(); index--;
87
+ return { ok: true, left: index < 0, origin, current: current() };
88
+ }
89
+ function forward() {
90
+ if (index + 1 >= entries.length) return failure("There is no later reading location.");
91
+ invalidate(); index++;
92
+ return { ok: true, left: false, current: current() };
93
+ }
94
+ function close() {
95
+ const root = origin;
96
+ invalidate(); entries = []; index = -1; origin = null;
97
+ return root;
98
+ }
99
+ function snapshot() {
100
+ return Object.freeze({ active: index >= 0, origin, index, current: current(),
101
+ entries: Object.freeze(entries.slice()), pending,
102
+ back: index > 0 ? entries[index - 1] : null,
103
+ forward: entries[index + 1] || null });
104
+ }
105
+ function isCurrent(item) {
106
+ const selected = current();
107
+ return Boolean(selected && item && selected.id === item.id && selected.revision === item.revision);
108
+ }
109
+ return Object.freeze({ begin, commit, cancel, rememberPosition, back, forward, close, snapshot, isCurrent });
110
+ }
@@ -0,0 +1,35 @@
1
+ // In-flight fetch and sendBeacon requests can reach the server out of order.
2
+ // Keep last-arrival behavior between different pages, but never let an older
3
+ // write from the same Studio tab overwrite one of its newer persisted snapshots.
4
+ export function createStudioMetadataWriteOrderTracker() {
5
+ const versions = new Map();
6
+
7
+ function prepare(kind, documentKey, ownership) {
8
+ if (!ownership) return Object.freeze({ commit() {} });
9
+ const writerId = String(ownership.writerId || "");
10
+ const writeVersion = Number(ownership.writeVersion);
11
+ if (!writerId || !Number.isSafeInteger(writeVersion) || writeVersion <= 0) return null;
12
+ const key = JSON.stringify([String(kind || ""), String(documentKey || ""), writerId]);
13
+ const previous = versions.get(key);
14
+ if (previous !== undefined && writeVersion < previous) return null;
15
+ let committed = false;
16
+ return Object.freeze({
17
+ commit() {
18
+ if (committed) return;
19
+ committed = true;
20
+ const current = versions.get(key);
21
+ if (current === undefined || writeVersion >= current) versions.set(key, writeVersion);
22
+ },
23
+ });
24
+ }
25
+
26
+ return Object.freeze({
27
+ prepare,
28
+ claim(kind, documentKey, ownership) {
29
+ const prepared = prepare(kind, documentKey, ownership);
30
+ if (!prepared) return false;
31
+ prepared.commit();
32
+ return true;
33
+ },
34
+ });
35
+ }
@@ -0,0 +1,24 @@
1
+ // Read-only transport/view chrome only. Neither helper grants resources,
2
+ // registers a workspace nor attaches a file to an editable buffer.
3
+ /** @param {{token: string, kind: string, path: string, resourceDir?: string, page?: number | null}} options */
4
+ export function buildStudioReadOnlyMediaUrl({ token, kind, path, resourceDir = "", page = null }) {
5
+ if (!["pdf", "image"].includes(kind) || typeof token !== "string" || !token || token.length > 512
6
+ || typeof path !== "string" || !/^(\/|[a-zA-Z]:[\\/])/.test(path) || path.length > 16384 || path.includes("\0")) throw Error("A resolved local PDF/image and authenticated token are required.");
7
+ // The receiving media resolvers accept a URI reference AFTER query decoding.
8
+ // Preserve this already-resolved literal filename through that second layer:
9
+ // URLSearchParams must not expose literal #/? or percent escapes to its decoder.
10
+ const params = new URLSearchParams({ token, path: encodeURIComponent(path), resourceDir });
11
+ return (kind === "pdf" ? "/pdf-resource?" : "/image-viewer?") + params.toString()
12
+ + (kind === "pdf" && Number.isSafeInteger(page) && page > 0 ? "#page=" + page : "");
13
+ }
14
+ export function buildStudioReadOnlyImagePage({ label, dataUrl, nonce }) {
15
+ if (typeof nonce !== "string" || !/^[a-f0-9]{32,64}$/.test(nonce)) throw Error("A fresh image-page nonce is required.");
16
+ if (typeof dataUrl !== "string" || !/^data:image\/(?:png|jpeg|gif|webp|svg\+xml);base64,[A-Za-z0-9+/]*={0,2}$/.test(dataUrl)) throw Error("Only supported image data may be shown in the image viewer.");
17
+ const escape = value => String(value || "Image").replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
18
+ const title = escape(label);
19
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>
20
+ <style nonce="${nonce}">html,body{margin:0;background:#171717;color:#e7e7e7;font:14px system-ui,sans-serif}header{position:sticky;top:0;display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px;background:#222;border-bottom:1px solid #444}h1{font-size:14px;font-weight:600;margin:0;overflow-wrap:anywhere}nav{display:flex;gap:6px}button{font:inherit;padding:5px 12px;background:transparent;color:inherit;border:1px solid #777;border-radius:5px;cursor:pointer}button[aria-pressed="true"]{background:#444}button:focus-visible{outline:2px solid #9bd;outline-offset:2px}.hint{color:#aaa;font-size:12px}main{padding:12px;overflow:auto}img{display:block;margin:auto}body[data-scale="fit"] img{max-width:100%;max-height:calc(100vh - 100px);width:auto;height:auto}body[data-scale="actual"] img{max-width:none;max-height:none}</style></head><body data-scale="fit">
21
+ <header><h1>${title}</h1><nav aria-label="Image size"><button id="mediaFit" type="button" aria-pressed="true">Fit</button><button id="mediaActual" type="button" aria-pressed="false">Actual size</button></nav><span class="hint">Read-only image</span></header>
22
+ <main><img src="${dataUrl}" alt="${title}"></main>
23
+ <script nonce="${nonce}">const fit=document.getElementById("mediaFit"),actual=document.getElementById("mediaActual");function scale(value){document.body.dataset.scale=value;fit.setAttribute("aria-pressed",String(value==="fit"));actual.setAttribute("aria-pressed",String(value==="actual"));}fit.addEventListener("click",()=>scale("fit"));actual.addEventListener("click",()=>scale("actual"));scale("fit");</script></body></html>`;
24
+ }
@@ -99,6 +99,9 @@ export function createStudioResourceGrantRegistry(options = {}) {
99
99
 
100
100
  function grantFile(pathInput, details = {}) {
101
101
  const resolved = canonicalExistingPath(pathInput, fallbackCwd(details), "file");
102
+ // Producers holding an already-canonical file identity may require exact
103
+ // equality before registration. A replaced symlink must add no new grant.
104
+ if (details.expectedCanonicalPath !== undefined && resolved.canonicalPath !== details.expectedCanonicalPath) throw new Error("The exact canonical file identity changed; no grant was added.");
102
105
  return addGrant("file", resolved.canonicalPath, normalizeGrantSource(details.source, "explicit-file"));
103
106
  }
104
107
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A Pi agent run outlives a response, request slot, agent_end, and automatic
3
+ * compaction/retry. Only agent_settled (or session teardown) retires this target.
4
+ * This ledger holds identity/facts, never prompt text or browser view state.
5
+ * @typedef {'direct'|'critique'|'show-me'|'annotation'} RunKind
6
+ * @typedef {{id:string, requestId:string|null, kind:RunKind|null, origin:'studio'|'outside'|'unknown', startedAt:number, stopping:boolean}} Run
7
+ */
8
+ export function createStudioRunLifecycle({ makeId = () => crypto.randomUUID(), now = () => Date.now() } = {}) {
9
+ /** @type {Run|null} */
10
+ let run = null;
11
+ /** @type {{reason:string, willRetry:boolean}|null} */
12
+ let compaction = null;
13
+ /** @type {'outside'|'unknown'} */
14
+ let inputOrigin = "unknown";
15
+ /** @param {{id:string,kind:string,startedAt?:number}|null} request */
16
+ function start(request = null) {
17
+ if (run) return snapshot(); // an automatic continuation is the same run
18
+ const owned = request && ["direct", "critique", "show-me", "annotation"].includes(request.kind);
19
+ run = { id: makeId(), requestId: owned ? request.id : null, kind: owned ? /** @type {RunKind} */ (request.kind) : null,
20
+ origin: owned ? "studio" : inputOrigin, startedAt: request?.startedAt ?? now(), stopping: false };
21
+ inputOrigin = "unknown";
22
+ return snapshot();
23
+ }
24
+ function snapshot() {
25
+ return { run: run ? { ...run, phase: run.stopping ? "stopping" : compaction ? "compacting" : "running", cancellable: run.origin === "studio" && !run.stopping } : null,
26
+ compaction: compaction ? { ...compaction } : null };
27
+ }
28
+ return {
29
+ start, snapshot,
30
+ /** Only an SDK input source proves outside origin; missing request IDs do not. @param {string} source */
31
+ input(source) { inputOrigin = source === "interactive" || source === "rpc" ? "outside" : "unknown"; },
32
+ /** @param {{reason?:string,willRetry?:boolean}} event */
33
+ compactStart(event = {}) { compaction = { reason: event.reason || "unknown", willRetry: event.willRetry === true }; },
34
+ compactEnd() { compaction = null; },
35
+ settled() { run = null; compaction = null; inputOrigin = "unknown"; },
36
+ /** Delivery is not settlement. A stale run token cannot stop a later run. */
37
+ /** @param {string} requestId @param {string|undefined} runId @param {()=>void} deliver */
38
+ stop(requestId, runId, deliver) {
39
+ if (!run || run.origin !== "studio" || run.requestId !== requestId || (runId && run.id !== runId)) return { ok: false, message: "That Studio run is no longer the active cancellation target." };
40
+ if (run.stopping) return { ok: true, kind: run.kind }; // idempotent acknowledgement, not a second abort
41
+ const target = run;
42
+ try { deliver(); } catch (error) { return { ok: false, message: "Failed to stop request: " + (error instanceof Error ? error.message : String(error)) }; }
43
+ if (run === target) target.stopping = true;
44
+ return { ok: true, kind: target.kind };
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,9 @@
1
+ // Pi's "system" theme takes its colours from the terminal. When the terminal
2
+ // reports none, Pi falls back to ANSI palette numbers that only the terminal
3
+ // can draw, so Studio cannot know the real colours (trial48 finding 2).
4
+ export function systemThemeLacksTrueColor(theme) {
5
+ if (!theme || theme.name !== "system") return false;
6
+ const read = fn => { try { return String(fn() ?? ""); } catch { return ""; } };
7
+ const sample = read(() => theme.getFgAnsi("text")) + read(() => theme.getBgAnsi("userMessageBg"));
8
+ return !/\x1b\[(?:38|48);2;/.test(sample);
9
+ }
Binary file
Binary file
Binary file
Binary file