deckide 3.5.24 → 3.5.26

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.
@@ -5,7 +5,7 @@ import { TERMINAL_BUFFER_LIMIT } from '../config.js';
5
5
  import { createHttpError, handleError, readJson } from '../utils/error.js';
6
6
  import { getDefaultShell } from '../utils/shell.js';
7
7
  import { saveTerminal, deleteTerminal as deleteTerminalFromDb } from '../utils/database.js';
8
- import { alignToUtf8Start } from '../utils/utf8.js';
8
+ import { alignToUtf8Start, skipPartialEscapeSequence } from '../utils/utf8.js';
9
9
  const DEFAULT_TERMINAL_TITLE = 'ターミナル';
10
10
  const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
11
11
  export function createTerminalRouter(db, decks, terminals) {
@@ -21,15 +21,22 @@ export function createTerminalRouter(db, decks, terminals) {
21
21
  if (chunk.length >= TERMINAL_BUFFER_LIMIT) {
22
22
  let cutPos = chunk.length - TERMINAL_BUFFER_LIMIT;
23
23
  cutPos = alignToUtf8Start(chunk, cutPos);
24
- const retainedChunk = Buffer.from(chunk.subarray(cutPos));
25
- session.bufferBase += session.bufferLength + cutPos;
26
- session.bufferChunks = [retainedChunk];
27
- session.bufferLength = retainedChunk.length;
24
+ let retained = Buffer.from(chunk.subarray(cutPos));
25
+ // Skip partial escape sequence at the new start
26
+ const escSkip = skipPartialEscapeSequence(retained, 0);
27
+ if (escSkip > 0) {
28
+ retained = Buffer.from(retained.subarray(escSkip));
29
+ }
30
+ session.bufferBase += session.bufferLength + cutPos + escSkip;
31
+ session.bufferChunks = [retained];
32
+ session.bufferLength = retained.length;
28
33
  return;
29
34
  }
30
35
  session.bufferChunks.push(Buffer.from(chunk));
31
36
  session.bufferLength += chunk.length;
37
+ let trimmed = false;
32
38
  while (session.bufferLength > TERMINAL_BUFFER_LIMIT && session.bufferChunks.length > 0) {
39
+ trimmed = true;
33
40
  const overflow = session.bufferLength - TERMINAL_BUFFER_LIMIT;
34
41
  const firstChunk = session.bufferChunks[0];
35
42
  if (firstChunk.length <= overflow) {
@@ -43,9 +50,9 @@ export function createTerminalRouter(db, decks, terminals) {
43
50
  session.bufferBase += cutPos;
44
51
  session.bufferLength -= cutPos;
45
52
  }
46
- // After removing whole chunks, the new first chunk may start with
47
- // orphaned UTF-8 continuation bytes from a character that spanned chunks.
48
- if (session.bufferChunks.length > 0) {
53
+ if (trimmed && session.bufferChunks.length > 0) {
54
+ // After removing whole chunks, the new first chunk may start with
55
+ // orphaned UTF-8 continuation bytes from a character that spanned chunks.
49
56
  const first = session.bufferChunks[0];
50
57
  const skip = alignToUtf8Start(first, 0);
51
58
  if (skip > 0) {
@@ -53,6 +60,16 @@ export function createTerminalRouter(db, decks, terminals) {
53
60
  session.bufferBase += skip;
54
61
  session.bufferLength -= skip;
55
62
  }
63
+ // Also skip past any partial ANSI CSI escape sequence at the start.
64
+ // Without this, orphaned parameter bytes like "100;50m" are rendered
65
+ // as literal text and shift all cursor positions.
66
+ const cur = session.bufferChunks[0];
67
+ const escSkip = skipPartialEscapeSequence(cur, 0);
68
+ if (escSkip > 0) {
69
+ session.bufferChunks[0] = Buffer.from(cur.subarray(escSkip));
70
+ session.bufferBase += escSkip;
71
+ session.bufferLength -= escSkip;
72
+ }
56
73
  }
57
74
  }
58
75
  function getUniqueTerminalTitle(deckId, requestedTitle) {
@@ -53,3 +53,157 @@ export function alignToUtf8End(buf, offset) {
53
53
  // The character is incomplete — exclude it
54
54
  return pos;
55
55
  }
56
+ /**
57
+ * Skip past a partial ANSI escape sequence at the start of a buffer.
58
+ *
59
+ * When a terminal buffer is trimmed from the front, the cut point may land
60
+ * inside an escape sequence. The leading ESC byte (and possibly the type
61
+ * indicator) is discarded, leaving orphaned payload bytes that xterm.js
62
+ * would render as literal text, shifting all subsequent cursor positions.
63
+ *
64
+ * Handled sequence types:
65
+ *
66
+ * CSI (ESC [) — e.g. "\x1b[38;2;100;50m"
67
+ * Detected as: '[' + params/intermediates + final byte
68
+ * or: bare params with ';' + final byte (ESC and '[' both trimmed)
69
+ *
70
+ * OSC (ESC ]) — e.g. "\x1b]0;title\x07"
71
+ * Detected as: ']' + text + BEL/ST terminator
72
+ *
73
+ * DCS (ESC P) — e.g. "\x1bP1$r...\x1b\\"
74
+ * Detected as: 'P' + text + ST terminator
75
+ *
76
+ * APC (ESC _) — e.g. "\x1b_...\x1b\\"
77
+ * Detected as: '_' + text + ST terminator
78
+ *
79
+ * Returns the number of bytes to skip (0 if no partial sequence detected).
80
+ */
81
+ export function skipPartialEscapeSequence(buf, offset) {
82
+ if (offset >= buf.length)
83
+ return 0;
84
+ const b = buf[offset];
85
+ // ── CSI: starts with '[' (ESC was trimmed) ──
86
+ if (b === 0x5B /* '[' */) {
87
+ return skipPartialCSI(buf, offset);
88
+ }
89
+ // ── OSC: starts with ']' (ESC was trimmed) ──
90
+ if (b === 0x5D /* ']' */) {
91
+ return skipStringSequence(buf, offset);
92
+ }
93
+ // ── DCS: starts with 'P' (ESC was trimmed) ──
94
+ // Only treat as DCS if followed by a parameter byte, '$', or printable
95
+ // control sequence byte — avoids false positive on words like "Path".
96
+ if (b === 0x50 /* 'P' */ && offset + 1 < buf.length) {
97
+ const next = buf[offset + 1];
98
+ if ((next >= 0x30 && next <= 0x3F) || next === 0x24 /* '$' */) {
99
+ return skipStringSequence(buf, offset);
100
+ }
101
+ }
102
+ // ── APC: starts with '_' (ESC was trimmed) ──
103
+ if (b === 0x5F /* '_' */) {
104
+ return skipStringSequence(buf, offset);
105
+ }
106
+ // ── Bare CSI params (both ESC and '[' trimmed) ──
107
+ if (b >= 0x30 && b <= 0x3F) {
108
+ return skipBareCSIParams(buf, offset);
109
+ }
110
+ return 0;
111
+ }
112
+ /**
113
+ * Skip a partial CSI sequence starting with '['.
114
+ * Pattern: '[' params intermediates final
115
+ */
116
+ function skipPartialCSI(buf, offset) {
117
+ const limit = Math.min(buf.length, offset + 128);
118
+ if (offset + 1 >= limit)
119
+ return 0;
120
+ const next = buf[offset + 1];
121
+ // Only treat as CSI if the next byte is a parameter (0x30-0x3F) or
122
+ // intermediate (0x20-0x2F) — avoids false positives like "[user@host"
123
+ if (next < 0x20 || (next > 0x3F && next < 0x40))
124
+ return 0;
125
+ // If next is already a final byte (letter), it could be "[H" (cursor home)
126
+ // or "[hello" — skip only the 2-byte "[H" style if the byte after final
127
+ // is a control char or ESC (strong signal it was a real sequence).
128
+ if (next >= 0x40 && next <= 0x7E) {
129
+ if (offset + 2 < buf.length) {
130
+ const after = buf[offset + 2];
131
+ if (after === 0x1B || after < 0x20)
132
+ return 2;
133
+ }
134
+ return 0;
135
+ }
136
+ let pos = offset + 1;
137
+ while (pos < limit) {
138
+ const c = buf[pos];
139
+ if ((c >= 0x30 && c <= 0x3F) || (c >= 0x20 && c <= 0x2F)) {
140
+ pos++;
141
+ continue;
142
+ }
143
+ if (c >= 0x40 && c <= 0x7E) {
144
+ return (pos + 1) - offset;
145
+ }
146
+ break;
147
+ }
148
+ return 0;
149
+ }
150
+ /**
151
+ * Skip bare CSI parameter bytes (both ESC and '[' were trimmed).
152
+ * Pattern: digits/';' (with at least one ';') + final byte
153
+ */
154
+ function skipBareCSIParams(buf, offset) {
155
+ const limit = Math.min(buf.length, offset + 128);
156
+ let pos = offset;
157
+ let hasSemicolon = false;
158
+ while (pos < limit) {
159
+ const c = buf[pos];
160
+ if (c >= 0x30 && c <= 0x3F) {
161
+ if (c === 0x3B)
162
+ hasSemicolon = true;
163
+ pos++;
164
+ }
165
+ else if (c >= 0x20 && c <= 0x2F) {
166
+ pos++;
167
+ }
168
+ else if (c >= 0x40 && c <= 0x7E) {
169
+ // Only skip if we saw ';' — avoids false positives like "5m"
170
+ return hasSemicolon ? (pos + 1) - offset : 0;
171
+ }
172
+ else {
173
+ break;
174
+ }
175
+ }
176
+ return 0;
177
+ }
178
+ /**
179
+ * Skip a string-type escape sequence (OSC / DCS / APC) that starts with
180
+ * the type indicator byte (']', 'P', or '_') — the leading ESC was trimmed.
181
+ *
182
+ * These sequences are terminated by:
183
+ * - BEL (0x07) — common for OSC
184
+ * - ST (ESC \ = 0x1B 0x5C) — standard for all
185
+ *
186
+ * We scan up to 4 KB for the terminator; if not found we skip nothing
187
+ * (the partial sequence might span far beyond what we want to discard).
188
+ */
189
+ function skipStringSequence(buf, offset) {
190
+ const limit = Math.min(buf.length, offset + 4096);
191
+ let pos = offset + 1; // skip the type indicator byte
192
+ while (pos < limit) {
193
+ const c = buf[pos];
194
+ if (c === 0x07) {
195
+ // BEL terminator
196
+ return (pos + 1) - offset;
197
+ }
198
+ if (c === 0x1B && pos + 1 < buf.length && buf[pos + 1] === 0x5C) {
199
+ // ST terminator (ESC \)
200
+ return (pos + 2) - offset;
201
+ }
202
+ // If we hit another ESC that's NOT followed by '\', it's a new sequence
203
+ if (c === 0x1B) {
204
+ return pos - offset;
205
+ }
206
+ pos++;
207
+ }
208
+ return 0;
209
+ }
package/dist/websocket.js CHANGED
@@ -149,11 +149,11 @@ function readBufferedRange(session, startOffset, endOffset) {
149
149
  if (relativeEnd <= relativeStart || session.bufferChunks.length === 0) {
150
150
  return Buffer.alloc(0);
151
151
  }
152
- // Materialize the raw range first, then align to UTF-8 boundaries.
153
- // This avoids splitting multi-byte characters that span chunk boundaries.
152
+ // Materialize the raw range into a contiguous copy so the caller
153
+ // is not affected if buffer chunks are later trimmed or reassigned.
154
154
  let raw;
155
155
  if (session.bufferChunks.length === 1) {
156
- raw = session.bufferChunks[0].subarray(relativeStart, relativeEnd);
156
+ raw = Buffer.from(session.bufferChunks[0].subarray(relativeStart, relativeEnd));
157
157
  }
158
158
  else {
159
159
  const slices = [];
@@ -172,7 +172,7 @@ function readBufferedRange(session, startOffset, endOffset) {
172
172
  const endInChunk = Math.min(chunk.length, relativeEnd - chunkStart);
173
173
  slices.push(chunk.subarray(startInChunk, endInChunk));
174
174
  }
175
- raw = slices.length === 1 ? slices[0] : Buffer.concat(slices);
175
+ raw = Buffer.concat(slices); // concat always creates a new Buffer
176
176
  }
177
177
  // Align start: skip orphaned continuation bytes
178
178
  const alignedStart = alignToUtf8Start(raw, 0);
@@ -181,7 +181,7 @@ function readBufferedRange(session, startOffset, endOffset) {
181
181
  if (alignedStart === 0 && alignedEnd === raw.length) {
182
182
  return raw;
183
183
  }
184
- return raw.subarray(alignedStart, alignedEnd);
184
+ return Buffer.from(raw.subarray(alignedStart, alignedEnd));
185
185
  }
186
186
  export function setupWebSocketServer(server, terminals) {
187
187
  const wss = new WebSocketServer({ server, maxPayload: MAX_MESSAGE_SIZE });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deckide",
3
- "version": "3.5.24",
3
+ "version": "3.5.26",
4
4
  "description": "Deck IDE - Browser-based IDE with terminal, file explorer, and git integration",
5
5
  "type": "module",
6
6
  "bin": {