@code-yeongyu/senpi-pty 2026.10.11 → 2026.10.12

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.
@@ -4,5 +4,16 @@ export declare function normalizeDimension(value: number | undefined, fallback:
4
4
  export declare function normalizeScrollback(value: number | undefined): number;
5
5
  export declare function normalizeReplayHistoryLength(cols: number, rows: number, scrollback: number): number;
6
6
  export declare function decodeInput(value: string | Uint8Array): string;
7
+ /**
8
+ * The last `maxLength` chars of `value`, sanitized, as a copy that does not keep `value` alive: a plain
9
+ * slice of an oversized chunk would pin the whole chunk in replay history (senpi#3170). The UTF-16
10
+ * round trip is lossless, lone surrogates included, and runs only when a chunk outgrows the cap.
11
+ */
12
+ export declare function sanitizedTail(value: string, maxLength: number): string;
13
+ /**
14
+ * Replaces lone surrogates with U+FFFD. Valid text comes back as the same string, and repaired text is
15
+ * joined from slices: the screen keeps these strings as replay history, and a string grown one character
16
+ * at a time stays a rope of per-character nodes (senpi#3170).
17
+ */
7
18
  export declare function sanitizeString(value: string): string;
8
19
  //# sourceMappingURL=screen-text.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  const DEFAULT_SCROLLBACK = 1000;
2
3
  const MIN_SIZE = 1;
3
4
  const MAX_SIZE = 10000;
@@ -25,27 +26,40 @@ export function decodeInput(value) {
25
26
  return value;
26
27
  return new TextDecoder("utf-8", { fatal: false }).decode(value);
27
28
  }
29
+ /**
30
+ * The last `maxLength` chars of `value`, sanitized, as a copy that does not keep `value` alive: a plain
31
+ * slice of an oversized chunk would pin the whole chunk in replay history (senpi#3170). The UTF-16
32
+ * round trip is lossless, lone surrogates included, and runs only when a chunk outgrows the cap.
33
+ */
34
+ export function sanitizedTail(value, maxLength) {
35
+ const tail = Buffer.from(value.slice(-maxLength), "utf16le").toString("utf16le");
36
+ return sanitizeString(tail);
37
+ }
38
+ /**
39
+ * Replaces lone surrogates with U+FFFD. Valid text comes back as the same string, and repaired text is
40
+ * joined from slices: the screen keeps these strings as replay history, and a string grown one character
41
+ * at a time stays a rope of per-character nodes (senpi#3170).
42
+ */
28
43
  export function sanitizeString(value) {
29
- let output = "";
44
+ const parts = [];
45
+ let copiedUpTo = 0;
30
46
  for (let index = 0; index < value.length; index += 1) {
31
47
  const code = value.charCodeAt(index);
32
- if (code >= 0xd800 && code <= 0xdbff) {
48
+ if (code < 0xd800 || code > 0xdfff)
49
+ continue;
50
+ if (code <= 0xdbff) {
33
51
  const next = value.charCodeAt(index + 1);
34
52
  if (next >= 0xdc00 && next <= 0xdfff) {
35
- output += value[index] + value[index + 1];
36
53
  index += 1;
54
+ continue;
37
55
  }
38
- else {
39
- output += "\uFFFD";
40
- }
41
- }
42
- else if (code >= 0xdc00 && code <= 0xdfff) {
43
- output += "\uFFFD";
44
- }
45
- else {
46
- output += value[index];
47
56
  }
57
+ parts.push(value.slice(copiedUpTo, index), "\uFFFD");
58
+ copiedUpTo = index + 1;
48
59
  }
49
- return output;
60
+ if (parts.length === 0)
61
+ return value;
62
+ parts.push(value.slice(copiedUpTo));
63
+ return parts.join("");
50
64
  }
51
65
  //# sourceMappingURL=screen-text.js.map
package/dist/screen.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import xterm from "@xterm/headless";
2
2
  import { MAX_PENDING_WRITE_CHARS, settleOperation, sharedSettler, } from "./screen-operations.js";
3
- import { decodeInput, normalizeDimension, normalizeReplayHistoryLength, normalizeScrollback, readLine, sanitizeString, } from "./screen-text.js";
3
+ import { decodeInput, normalizeDimension, normalizeReplayHistoryLength, normalizeScrollback, readLine, sanitizedTail, sanitizeString, } from "./screen-text.js";
4
4
  const XtermTerminal = xterm.Terminal;
5
5
  const DEFAULT_COLS = 80;
6
6
  const DEFAULT_ROWS = 24;
@@ -191,7 +191,7 @@ export class TerminalScreen {
191
191
  boundReplayPayload(payload) {
192
192
  if (payload.length <= this.maxReplayHistoryLength)
193
193
  return payload;
194
- return sanitizeString(payload.slice(-this.maxReplayHistoryLength));
194
+ return sanitizedTail(payload, this.maxReplayHistoryLength);
195
195
  }
196
196
  async drain() {
197
197
  if (this.draining)
@@ -270,7 +270,7 @@ export class TerminalScreen {
270
270
  const [onlyChunk] = this.history;
271
271
  if (onlyChunk === undefined)
272
272
  return;
273
- const trimmed = sanitizeString(onlyChunk.slice(-this.maxReplayHistoryLength));
273
+ const trimmed = sanitizedTail(onlyChunk, this.maxReplayHistoryLength);
274
274
  this.history[0] = trimmed;
275
275
  this.historyLength = trimmed.length;
276
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-pty",
3
- "version": "2026.10.11",
3
+ "version": "2026.10.12",
4
4
  "description": "Typed PTY session loader for Senpi persistent terminals",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "clean": "shx rm -rf dist",
35
35
  "build": "tsgo -p tsconfig.build.json",
36
- "test": "vitest --run test",
36
+ "test": "node ../../scripts/run-tests.mjs vitest --run test",
37
37
  "check:prebuild": "node native/check-prebuild-fresh.mjs",
38
38
  "prepublishOnly": "npm run clean && npm run build"
39
39
  },