pi-studio 0.9.56 → 0.9.57
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 +16 -0
- package/README.md +17 -3
- package/ROADMAP.md +16 -1
- package/client/studio-client.js +65 -7
- package/index.ts +235 -112
- package/package.json +1 -1
- package/shared/REPL_SESSION_RECORD_PROTOCOL.md +24 -1
- package/shared/repl-control-files.js +158 -0
- package/shared/repl-submission-display.js +227 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.57",
|
|
4
4
|
"description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,7 +57,7 @@ Each entry has a stable `id`, optional `requestId`, timestamps, session/runtime
|
|
|
57
57
|
|
|
58
58
|
Record updates take a short cross-process directory lock, read the latest snapshot, upsert by stable entry ID, and replace the snapshot atomically. A stale lock can be recovered. The implementation retains at most 300 entries, bounds individual prose/code/output fields, and caps the serialized record at 16 MiB by dropping the oldest entries first.
|
|
59
59
|
|
|
60
|
-
Attribution-sensitive sends also take a separate cross-client send lease. A compatible client holds it from the pre-send pane capture through the completion/output capture. The lease has an owner token, heartbeat, bounded wait, and stale recovery. If a caller times out or aborts after submission, the live client continues heartbeating the lease until the runtime completion
|
|
60
|
+
Attribution-sensitive sends also take a separate cross-client send lease. A compatible client holds it from the pre-send pane capture through the completion/output capture. The lease has an owner token, heartbeat, bounded wait, and stale recovery. If a caller times out or aborts after submission, the live client continues heartbeating the lease until the runtime completion signal appears or that exact tmux session lifetime ends; a caller timeout does not imply that submitted code stopped. This prevents `pi-repl` and `pi-studio` from concurrently claiming each other's output; it cannot prevent a person typing directly into an attached tmux pane.
|
|
61
61
|
|
|
62
62
|
## Clean record versus raw history
|
|
63
63
|
|
|
@@ -65,6 +65,29 @@ The clean record includes submissions whose semantic boundaries are known to a c
|
|
|
65
65
|
|
|
66
66
|
Canonical Markdown exports identify origin, mode, status, runtime, and timestamp and include this direct-input limitation.
|
|
67
67
|
|
|
68
|
+
## Optional raw-history display and alignment anchors
|
|
69
|
+
|
|
70
|
+
Compatible clients may add protocol-independent submission displays to the raw pane while retaining the same clean record. Display version 1 derives a non-secret 12-hex-character anchor as the first 12 characters of SHA-256 over `pi-repl-submission-display-v1`, a NUL byte, and the stable clean-record entry ID. The entry ID itself is not written to the pane.
|
|
71
|
+
|
|
72
|
+
**Off** is the default and writes no optional display or alignment anchors. Opt-in **Summary** shows a short submission in full, truncating after 6 source lines or 600 source characters. **Full** raises those bounds to 40 lines or 4,000 characters and warns that source becomes part of persistent raw terminal history. Display text normalizes newlines and tabs, removes trailing display whitespace, and escapes terminal, line-separator, and bidirectional control characters.
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
── pi-repl · a1b2c3d4e5f6 · 2 lines ──
|
|
76
|
+
│ x = 1
|
|
77
|
+
│ x + 1
|
|
78
|
+
── output ──
|
|
79
|
+
2
|
|
80
|
+
── done · a1b2c3d4e5f6 ──
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The compact submitted and completion anchors remain in raw tmux history for human readability and deterministic future alignment. A plain unanchored `── output ──` divider separates the source preview from runtime output without repeating the ID or other metadata. Clients remove the exact request-specific header, source preview, divider, and footer from captured tool output and clean-record output. These markers are presentation metadata, not clean-record authority: missing, malformed, duplicated, or user-produced marker-like text must never cause inferred raw activity to be promoted silently into protocol-v1 entries.
|
|
84
|
+
|
|
85
|
+
## Runtime control files (outside protocol v1)
|
|
86
|
+
|
|
87
|
+
Runtime-specific source wrappers and completion files are client implementation details, not shared-record state or authority. Compatible clients use compact request-unique names under a current-user-owned mode-`0700` `/tmp/pi-rc-<user-key>` root on POSIX systems, with mode-`0600` source files created exclusively. This avoids both verbose per-session paths and fixed global filenames that can collide across clients, processes, tmux servers, or runtimes.
|
|
88
|
+
|
|
89
|
+
A client removes the source and completion files after output capture. If a send times out or is aborted after submission, the same watcher that retains any shared lease also retains those files until the wrapper signals completion or the exact session lifetime disappears. Orphans left by a process crash are pruned after 24 hours on a later send. These files remain protocol-independent: their names and presence never turn raw pane activity into a clean-record entry.
|
|
90
|
+
|
|
68
91
|
## Compatibility
|
|
69
92
|
|
|
70
93
|
A client that sees an unsupported version leaves it untouched. Existing tmux sessions gain v1 metadata lazily when inspected or used. Studio may import legacy browser-local entries as `pi-studio` entries using their stable IDs, making retries idempotent.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { basename, dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const REPL_CONTROL_TOKEN_BYTES = 8;
|
|
18
|
+
const REPL_CONTROL_STALE_MS = 24 * 60 * 60 * 1_000;
|
|
19
|
+
const prunedRoots = new Set();
|
|
20
|
+
|
|
21
|
+
function currentUid() {
|
|
22
|
+
return typeof process.getuid === "function" ? process.getuid() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getPrivateReplControlRoot() {
|
|
26
|
+
const uid = currentUid();
|
|
27
|
+
const base = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
28
|
+
const userKey = uid === null ? "user" : uid.toString(36);
|
|
29
|
+
return join(base, `pi-rc-${userKey}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertPrivateReplControlRoot(root) {
|
|
33
|
+
const stats = lstatSync(root);
|
|
34
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
35
|
+
throw new Error(`REPL control root is not a real directory: ${root}`);
|
|
36
|
+
}
|
|
37
|
+
const uid = currentUid();
|
|
38
|
+
if (uid !== null && stats.uid !== uid) {
|
|
39
|
+
throw new Error(`REPL control root is not owned by the current user: ${root}`);
|
|
40
|
+
}
|
|
41
|
+
if (process.platform !== "win32" && (stats.mode & 0o777) !== 0o700) {
|
|
42
|
+
throw new Error(`REPL control root must have mode 0700: ${root}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pruneStaleReplControlFiles(root, now = Date.now()) {
|
|
47
|
+
if (prunedRoots.has(root)) return;
|
|
48
|
+
prunedRoots.add(root);
|
|
49
|
+
let entries = [];
|
|
50
|
+
try {
|
|
51
|
+
entries = readdirSync(root);
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const uid = currentUid();
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (!/^[a-f0-9]{16}\.(?:[A-Za-z0-9]{1,8}|done)$/.test(entry)) continue;
|
|
58
|
+
const file = join(root, entry);
|
|
59
|
+
try {
|
|
60
|
+
const stats = lstatSync(file);
|
|
61
|
+
if (!stats.isFile() || stats.isSymbolicLink()) continue;
|
|
62
|
+
if (uid !== null && stats.uid !== uid) continue;
|
|
63
|
+
if (now - stats.mtimeMs < REPL_CONTROL_STALE_MS) continue;
|
|
64
|
+
unlinkSync(file);
|
|
65
|
+
} catch {
|
|
66
|
+
// Another process may have removed the same stale file.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ensurePrivateReplControlRoot(root = getPrivateReplControlRoot()) {
|
|
72
|
+
let created = false;
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(root, { mode: 0o700 });
|
|
75
|
+
created = true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
|
|
78
|
+
}
|
|
79
|
+
if (created && process.platform !== "win32") chmodSync(root, 0o700);
|
|
80
|
+
assertPrivateReplControlRoot(root);
|
|
81
|
+
pruneStaleReplControlFiles(root);
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeExtension(extension) {
|
|
86
|
+
const normalized = String(extension || "").replace(/^\.+/, "");
|
|
87
|
+
if (!/^[A-Za-z0-9]{1,8}$/.test(normalized)) {
|
|
88
|
+
throw new Error(`Invalid REPL control-file extension: ${extension}`);
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create and populate one private, collision-resistant REPL control file.
|
|
95
|
+
* The builder receives the final paths so it can embed the matching done-file
|
|
96
|
+
* path in the runtime-specific wrapper.
|
|
97
|
+
*/
|
|
98
|
+
export function createPrivateReplControlFiles(options) {
|
|
99
|
+
const extension = normalizeExtension(options?.extension);
|
|
100
|
+
const root = ensurePrivateReplControlRoot(options?.root || getPrivateReplControlRoot());
|
|
101
|
+
if (typeof options?.buildSource !== "function") {
|
|
102
|
+
throw new Error("REPL control-file source builder is required.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
106
|
+
const token = randomBytes(REPL_CONTROL_TOKEN_BYTES).toString("hex");
|
|
107
|
+
const paths = {
|
|
108
|
+
dir: root,
|
|
109
|
+
sourceFile: join(root, `${token}.${extension}`),
|
|
110
|
+
doneFile: join(root, `${token}.done`),
|
|
111
|
+
};
|
|
112
|
+
if (existsSync(paths.doneFile)) continue;
|
|
113
|
+
|
|
114
|
+
let descriptor;
|
|
115
|
+
try {
|
|
116
|
+
descriptor = openSync(paths.sourceFile, "wx", 0o600);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error && typeof error === "object" && error.code === "EEXIST") continue;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let complete = false;
|
|
123
|
+
try {
|
|
124
|
+
const source = String(options.buildSource(paths));
|
|
125
|
+
writeFileSync(descriptor, source, "utf8");
|
|
126
|
+
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
|
127
|
+
complete = true;
|
|
128
|
+
return paths;
|
|
129
|
+
} finally {
|
|
130
|
+
closeSync(descriptor);
|
|
131
|
+
if (!complete) {
|
|
132
|
+
try {
|
|
133
|
+
unlinkSync(paths.sourceFile);
|
|
134
|
+
} catch {
|
|
135
|
+
// Preserve the source-builder error.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new Error("Could not allocate a unique REPL control file.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function cleanupPrivateReplControlFiles(paths) {
|
|
144
|
+
if (!paths || typeof paths !== "object") return;
|
|
145
|
+
const root = String(paths.dir || "");
|
|
146
|
+
const sourceFile = String(paths.sourceFile || "");
|
|
147
|
+
const doneFile = String(paths.doneFile || "");
|
|
148
|
+
const sourceName = basename(sourceFile);
|
|
149
|
+
const token = sourceName.match(/^([a-f0-9]{16})\.[A-Za-z0-9]{1,8}$/)?.[1];
|
|
150
|
+
if (!token || dirname(sourceFile) !== root || dirname(doneFile) !== root || basename(doneFile) !== `${token}.done`) return;
|
|
151
|
+
for (const file of [sourceFile, doneFile]) {
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(file);
|
|
154
|
+
} catch {
|
|
155
|
+
// Cleanup is idempotent and best effort.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const REPL_SUBMISSION_DISPLAY_VERSION = 1;
|
|
4
|
+
export const DEFAULT_REPL_SUBMISSION_ECHO_MODE = "off";
|
|
5
|
+
export const REPL_SUBMISSION_ECHO_MODES = Object.freeze(["off", "summary", "full"]);
|
|
6
|
+
export const REPL_SUBMISSION_SUMMARY_MAX_CHARS = 600;
|
|
7
|
+
export const REPL_SUBMISSION_SUMMARY_MAX_LINES = 6;
|
|
8
|
+
export const REPL_SUBMISSION_FULL_MAX_CHARS = 4_000;
|
|
9
|
+
export const REPL_SUBMISSION_FULL_MAX_LINES = 40;
|
|
10
|
+
|
|
11
|
+
const UNSAFE_UNICODE_PATTERN = /[\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
12
|
+
const OTHER_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
|
13
|
+
const COMPACT_BEGIN_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) · ([a-f0-9]{12}) · ([1-9]\d*) (line|lines) ──$/;
|
|
14
|
+
const COMPACT_OUTPUT_MARKER = "── output ──";
|
|
15
|
+
const COMPACT_END_MARKER_PATTERN = /^── done · ([a-f0-9]{12}) ──$/;
|
|
16
|
+
const LEGACY_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) (submitted|output|complete)(?: · ([1-9]\d*) (line|lines))? · ([a-f0-9]{12}) ──$/;
|
|
17
|
+
|
|
18
|
+
export function normalizeReplSubmissionEchoMode(value, fallback = DEFAULT_REPL_SUBMISSION_ECHO_MODE) {
|
|
19
|
+
const normalizedFallback = REPL_SUBMISSION_ECHO_MODES.includes(String(fallback || "").trim().toLowerCase())
|
|
20
|
+
? String(fallback).trim().toLowerCase()
|
|
21
|
+
: DEFAULT_REPL_SUBMISSION_ECHO_MODE;
|
|
22
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
23
|
+
return REPL_SUBMISSION_ECHO_MODES.includes(normalized) ? normalized : normalizedFallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeCodePoint(char) {
|
|
27
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
28
|
+
return codePoint <= 0xff
|
|
29
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
30
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeReplSubmissionDisplayText(value) {
|
|
34
|
+
return String(value || "")
|
|
35
|
+
.replace(/\r\n?/g, "\n")
|
|
36
|
+
.replace(/\t/g, " ")
|
|
37
|
+
.replace(OTHER_CONTROL_PATTERN, escapeCodePoint)
|
|
38
|
+
.replace(UNSAFE_UNICODE_PATTERN, escapeCodePoint);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateCodePoints(value, maxChars) {
|
|
42
|
+
const chars = Array.from(String(value || ""));
|
|
43
|
+
if (chars.length <= maxChars) return { text: chars.join(""), truncated: false };
|
|
44
|
+
return { text: `${chars.slice(0, Math.max(0, maxChars - 1)).join("")}…`, truncated: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeDisplayCode(code) {
|
|
48
|
+
const sanitized = sanitizeReplSubmissionDisplayText(code)
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.replace(/ +$/g, ""))
|
|
51
|
+
.join("\n")
|
|
52
|
+
.replace(/\n+$/, "");
|
|
53
|
+
return sanitized || "(empty submission)";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatPreviewLine(line) {
|
|
57
|
+
return line ? `│ ${line}` : "│";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildBoundedPreviewLines(codeLines, maxLines, maxChars) {
|
|
61
|
+
const shown = [];
|
|
62
|
+
let usedChars = 0;
|
|
63
|
+
let truncated = false;
|
|
64
|
+
for (let index = 0; index < codeLines.length; index += 1) {
|
|
65
|
+
if (shown.length >= maxLines) {
|
|
66
|
+
truncated = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const line = codeLines[index];
|
|
70
|
+
const remaining = maxChars - usedChars;
|
|
71
|
+
if (remaining <= 0) {
|
|
72
|
+
truncated = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const clipped = truncateCodePoints(line, remaining);
|
|
76
|
+
shown.push(formatPreviewLine(clipped.text));
|
|
77
|
+
usedChars += Array.from(clipped.text).length + 1;
|
|
78
|
+
if (clipped.truncated) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (shown.length < codeLines.length) truncated = true;
|
|
84
|
+
if (truncated) shown.push(`│ … preview truncated; ${codeLines.length} ${codeLines.length === 1 ? "line" : "lines"} total`);
|
|
85
|
+
return shown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createReplSubmissionDisplay(details = {}) {
|
|
89
|
+
const mode = normalizeReplSubmissionEchoMode(details.mode);
|
|
90
|
+
const origin = String(details.origin || "pi")
|
|
91
|
+
.trim()
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "")
|
|
95
|
+
.slice(0, 32) || "pi";
|
|
96
|
+
const entryId = String(details.entryId || "");
|
|
97
|
+
const anchorId = createHash("sha256")
|
|
98
|
+
.update(`pi-repl-submission-display-v${REPL_SUBMISSION_DISPLAY_VERSION}\0${entryId}`, "utf8")
|
|
99
|
+
.digest("hex")
|
|
100
|
+
.slice(0, 12);
|
|
101
|
+
const displayCode = normalizeDisplayCode(details.code);
|
|
102
|
+
const codeLines = displayCode.split("\n");
|
|
103
|
+
const lineLabel = `${codeLines.length} ${codeLines.length === 1 ? "line" : "lines"}`;
|
|
104
|
+
const beginMarker = `── ${origin} · ${anchorId} · ${lineLabel} ──`;
|
|
105
|
+
const outputMarker = COMPACT_OUTPUT_MARKER;
|
|
106
|
+
const endMarker = `── done · ${anchorId} ──`;
|
|
107
|
+
const enabled = mode !== "off";
|
|
108
|
+
const previewLines = !enabled
|
|
109
|
+
? []
|
|
110
|
+
: mode === "full"
|
|
111
|
+
? buildBoundedPreviewLines(codeLines, REPL_SUBMISSION_FULL_MAX_LINES, REPL_SUBMISSION_FULL_MAX_CHARS)
|
|
112
|
+
: buildBoundedPreviewLines(codeLines, REPL_SUBMISSION_SUMMARY_MAX_LINES, REPL_SUBMISSION_SUMMARY_MAX_CHARS);
|
|
113
|
+
return {
|
|
114
|
+
version: REPL_SUBMISSION_DISPLAY_VERSION,
|
|
115
|
+
mode,
|
|
116
|
+
enabled,
|
|
117
|
+
origin,
|
|
118
|
+
entryId,
|
|
119
|
+
anchorId,
|
|
120
|
+
beginMarker,
|
|
121
|
+
outputMarker,
|
|
122
|
+
endMarker,
|
|
123
|
+
previewLines,
|
|
124
|
+
prefixLines: enabled ? [beginMarker, ...previewLines, outputMarker] : [],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseLineCount(countText, countLabel) {
|
|
129
|
+
const lineCount = Number(countText);
|
|
130
|
+
if (!Number.isSafeInteger(lineCount) || lineCount < 1) return null;
|
|
131
|
+
if ((lineCount === 1) !== (countLabel === "line")) return null;
|
|
132
|
+
return lineCount;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseReplSubmissionDisplayMarker(line) {
|
|
136
|
+
const normalized = String(line || "").replace(/\r$/, "");
|
|
137
|
+
const compactBegin = normalized.match(COMPACT_BEGIN_MARKER_PATTERN);
|
|
138
|
+
if (compactBegin) {
|
|
139
|
+
const [, origin, anchorId, countText, countLabel] = compactBegin;
|
|
140
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
141
|
+
return lineCount === null
|
|
142
|
+
? null
|
|
143
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase: "submitted", anchorId, lineCount };
|
|
144
|
+
}
|
|
145
|
+
if (normalized === COMPACT_OUTPUT_MARKER) {
|
|
146
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, phase: "output" };
|
|
147
|
+
}
|
|
148
|
+
const compactEnd = normalized.match(COMPACT_END_MARKER_PATTERN);
|
|
149
|
+
if (compactEnd) {
|
|
150
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, phase: "complete", anchorId: compactEnd[1] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Accept the earlier three-marker development format so raw histories made
|
|
154
|
+
// while testing it remain interpretable; new displays do not emit this form.
|
|
155
|
+
const legacy = normalized.match(LEGACY_MARKER_PATTERN);
|
|
156
|
+
if (!legacy) return null;
|
|
157
|
+
const [, origin, phase, countText, countLabel, anchorId] = legacy;
|
|
158
|
+
if (phase === "submitted") {
|
|
159
|
+
if (!countText) return null;
|
|
160
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
161
|
+
return lineCount === null
|
|
162
|
+
? null
|
|
163
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase, anchorId, lineCount, legacy: true };
|
|
164
|
+
}
|
|
165
|
+
if (countText) return null;
|
|
166
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase, anchorId, legacy: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function consumeExactDisplayLine(value, offset, line) {
|
|
170
|
+
if (!value.startsWith(line, offset)) return null;
|
|
171
|
+
let end = offset + line.length;
|
|
172
|
+
while (end < value.length && (value[end] === " " || value[end] === "\t")) end += 1;
|
|
173
|
+
if (end < value.length && value[end] !== "\n") return null;
|
|
174
|
+
return value[end] === "\n" ? end + 1 : end;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findExactDisplayLine(value, line, useLast = false) {
|
|
178
|
+
let index = useLast ? value.lastIndexOf(line) : value.indexOf(line);
|
|
179
|
+
while (index >= 0) {
|
|
180
|
+
const startsLine = index === 0 || value[index - 1] === "\n";
|
|
181
|
+
const end = consumeExactDisplayLine(value, index, line);
|
|
182
|
+
if (startsLine && end !== null) return { index, end };
|
|
183
|
+
if (useLast) {
|
|
184
|
+
if (index === 0) break;
|
|
185
|
+
index = value.lastIndexOf(line, index - 1);
|
|
186
|
+
} else {
|
|
187
|
+
index = value.indexOf(line, index + line.length);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function removeMarkerLine(value, marker, useLast = false) {
|
|
194
|
+
const found = findExactDisplayLine(value, marker, useLast);
|
|
195
|
+
return found ? value.slice(0, found.index) + value.slice(found.end) : value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function stripReplSubmissionDisplay(output, display) {
|
|
199
|
+
let value = String(output || "").replace(/\r\n?/g, "\n");
|
|
200
|
+
if (!display || display.enabled !== true) return value;
|
|
201
|
+
const begin = findExactDisplayLine(value, display.beginMarker);
|
|
202
|
+
if (begin) {
|
|
203
|
+
const beginIndex = begin.index;
|
|
204
|
+
const afterBegin = value.slice(begin.end);
|
|
205
|
+
const outputDivider = display.outputMarker
|
|
206
|
+
? findExactDisplayLine(afterBegin, display.outputMarker)
|
|
207
|
+
: null;
|
|
208
|
+
if (outputDivider) {
|
|
209
|
+
// The plain divider is an unambiguous boundary because it is emitted
|
|
210
|
+
// before user code runs. Removing through it is resilient to terminal
|
|
211
|
+
// wrapping or whitespace changes within the displayed source preview.
|
|
212
|
+
const suffixStart = begin.end + outputDivider.end;
|
|
213
|
+
value = value.slice(0, beginIndex) + value.slice(suffixStart);
|
|
214
|
+
} else {
|
|
215
|
+
// If the runtime disappears mid-prefix, remove only contiguous exact
|
|
216
|
+
// request lines and preserve any different error text after them.
|
|
217
|
+
let suffixStart = beginIndex;
|
|
218
|
+
for (const line of display.prefixLines) {
|
|
219
|
+
const next = consumeExactDisplayLine(value, suffixStart, line);
|
|
220
|
+
if (next === null) break;
|
|
221
|
+
suffixStart = next;
|
|
222
|
+
}
|
|
223
|
+
value = value.slice(0, beginIndex) + value.slice(suffixStart);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return removeMarkerLine(value, display.endMarker, true);
|
|
227
|
+
}
|