@deepseek-harness-tui/dsh-tui 0.11.2 → 0.12.0

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 (168) hide show
  1. package/README.md +21 -5
  2. package/bin/dsh-tui.js +30 -3
  3. package/cordis.patch.yml +24 -5
  4. package/guide/dsh-tui-guide/SKILL.md +37 -0
  5. package/guide/dsh-tui-guide/configuration.en.md +410 -0
  6. package/guide/dsh-tui-guide/configuration.md +349 -0
  7. package/guide/dsh-tui-guide/getting-started.en.md +398 -0
  8. package/guide/dsh-tui-guide/getting-started.md +397 -0
  9. package/guide/dsh-tui-guide/interaction.en.md +674 -0
  10. package/guide/dsh-tui-guide/interaction.md +747 -0
  11. package/guide/dsh-tui-guide/migrate.en.md +166 -0
  12. package/guide/dsh-tui-guide/migrate.md +132 -0
  13. package/guide/dsh-tui-guide/plugins.en.md +68 -0
  14. package/guide/dsh-tui-guide/plugins.md +56 -0
  15. package/guide/dsh-tui-guide/themes.en.md +192 -0
  16. package/guide/dsh-tui-guide/themes.md +185 -0
  17. package/guide/dsh-tui-guide/user-guide.en.md +608 -0
  18. package/guide/dsh-tui-guide/user-guide.md +586 -0
  19. package/guide/dsh-tui-guide/vscode.en.md +301 -0
  20. package/guide/dsh-tui-guide/vscode.md +272 -0
  21. package/lib/settings.json +5 -5
  22. package/lib/types/adapter/channel/ui-policy.d.ts +3 -2
  23. package/lib/types/adapter/channel/ui-policy.d.ts.map +1 -1
  24. package/lib/types/adapter/channel/ui-policy.js +4 -0
  25. package/lib/types/adapter/ports/channel-settings.d.ts +2 -1
  26. package/lib/types/adapter/ports/channel-settings.d.ts.map +1 -1
  27. package/lib/types/adapter/ports/channel-ui.d.ts +29 -2
  28. package/lib/types/adapter/ports/channel-ui.d.ts.map +1 -1
  29. package/lib/types/adapter/ports/channel-view.d.ts +43 -9
  30. package/lib/types/adapter/ports/channel-view.d.ts.map +1 -1
  31. package/lib/types/components/Chat/JobCard.d.ts +9 -0
  32. package/lib/types/components/Chat/JobCard.d.ts.map +1 -1
  33. package/lib/types/components/Chat/JobCard.js +42 -15
  34. package/lib/types/components/Chat/SubagentMessage.js +6 -6
  35. package/lib/types/components/JobsPanel.d.ts.map +1 -1
  36. package/lib/types/components/JobsPanel.js +80 -33
  37. package/lib/types/components/MessageList.js +3 -3
  38. package/lib/types/components/PresetPicker.d.ts +4 -0
  39. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  40. package/lib/types/components/PresetPicker.js +5 -1
  41. package/lib/types/components/PromptInput.d.ts.map +1 -1
  42. package/lib/types/components/PromptInput.js +12 -1
  43. package/lib/types/components/StarPrompt.d.ts +6 -0
  44. package/lib/types/components/StarPrompt.d.ts.map +1 -1
  45. package/lib/types/components/StarPrompt.js +45 -0
  46. package/lib/types/components/SubagentCard.d.ts.map +1 -1
  47. package/lib/types/components/SubagentCard.js +8 -8
  48. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
  49. package/lib/types/components/SubagentDetailScene.js +108 -10
  50. package/lib/types/components/WhaleGirl.d.ts +6 -0
  51. package/lib/types/components/WhaleGirl.d.ts.map +1 -1
  52. package/lib/types/components/WhaleGirl.js +18 -0
  53. package/lib/types/components/messages/AssistantThinkingMessage.js +4 -4
  54. package/lib/types/components/trajectory/MiniWake.d.ts +11 -1
  55. package/lib/types/components/trajectory/MiniWake.d.ts.map +1 -1
  56. package/lib/types/components/trajectory/MiniWake.js +9 -3
  57. package/lib/types/dsh-adapter/channel/external-commands.d.ts.map +1 -1
  58. package/lib/types/dsh-adapter/channel/external-commands.js +8 -2
  59. package/lib/types/dsh-adapter/channel/job-projection.d.ts +1 -0
  60. package/lib/types/dsh-adapter/channel/job-projection.d.ts.map +1 -1
  61. package/lib/types/dsh-adapter/channel/job-projection.js +88 -3
  62. package/lib/types/dsh-adapter/channel/mode-actions.d.ts +13 -0
  63. package/lib/types/dsh-adapter/channel/mode-actions.d.ts.map +1 -1
  64. package/lib/types/dsh-adapter/channel/mode-actions.js +48 -7
  65. package/lib/types/dsh-adapter/channel/mode-permission-actions.d.ts.map +1 -1
  66. package/lib/types/dsh-adapter/channel/mode-permission-actions.js +25 -9
  67. package/lib/types/dsh-adapter/channel/preferences.d.ts +1 -1
  68. package/lib/types/dsh-adapter/channel/preferences.d.ts.map +1 -1
  69. package/lib/types/dsh-adapter/channel/preferences.js +17 -7
  70. package/lib/types/dsh-adapter/channel/state.d.ts +4 -2
  71. package/lib/types/dsh-adapter/channel/state.d.ts.map +1 -1
  72. package/lib/types/dsh-adapter/channel/state.js +1 -1
  73. package/lib/types/dsh-adapter/channel/subagent-projection.d.ts.map +1 -1
  74. package/lib/types/dsh-adapter/channel/subagent-projection.js +5 -0
  75. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  76. package/lib/types/dsh-adapter/channel.js +16 -1
  77. package/lib/types/dsh-adapter/contract.d.ts +5 -4
  78. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  79. package/lib/types/dsh-adapter/contract.js +9 -3
  80. package/lib/types/dsh-adapter/jobs.d.ts +82 -16
  81. package/lib/types/dsh-adapter/jobs.d.ts.map +1 -1
  82. package/lib/types/dsh-adapter/jobs.js +104 -9
  83. package/lib/types/dsh-adapter/oauth/bonus.d.ts +27 -0
  84. package/lib/types/dsh-adapter/oauth/bonus.d.ts.map +1 -0
  85. package/lib/types/dsh-adapter/oauth/bonus.js +69 -0
  86. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/command.d.ts +2 -1
  87. package/lib/types/dsh-adapter/oauth/command.d.ts.map +1 -0
  88. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/command.js +7 -4
  89. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/credentials.d.ts +13 -10
  90. package/lib/types/dsh-adapter/oauth/credentials.d.ts.map +1 -0
  91. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/credentials.js +61 -23
  92. package/lib/types/dsh-adapter/oauth/deepseek.d.ts +39 -0
  93. package/lib/types/dsh-adapter/oauth/deepseek.d.ts.map +1 -0
  94. package/lib/types/dsh-adapter/oauth/deepseek.js +189 -0
  95. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/index.d.ts +24 -16
  96. package/lib/types/dsh-adapter/oauth/index.d.ts.map +1 -0
  97. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/index.js +34 -20
  98. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/interaction.d.ts +14 -12
  99. package/lib/types/dsh-adapter/oauth/interaction.d.ts.map +1 -0
  100. package/lib/types/dsh-adapter/oauth/interaction.js +283 -0
  101. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/opener.d.ts +2 -1
  102. package/lib/types/dsh-adapter/oauth/opener.d.ts.map +1 -0
  103. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/opener.js +1 -1
  104. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/pi-ai.d.ts +10 -3
  105. package/lib/types/dsh-adapter/oauth/pi-ai.d.ts.map +1 -0
  106. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/pi-ai.js +8 -3
  107. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/profiles.d.ts +6 -3
  108. package/lib/types/dsh-adapter/oauth/profiles.d.ts.map +1 -0
  109. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/profiles.js +11 -3
  110. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/service.d.ts +14 -7
  111. package/lib/types/dsh-adapter/oauth/service.d.ts.map +1 -0
  112. package/{node_modules/@deepseek-harness-tui/dsh-auth/lib → lib/types/dsh-adapter/oauth}/service.js +82 -17
  113. package/lib/types/dsh-adapter/plugin.d.ts +8 -1
  114. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  115. package/lib/types/dsh-adapter/plugin.js +72 -12
  116. package/lib/types/dsh-adapter/presets.d.ts +6 -4
  117. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  118. package/lib/types/dsh-adapter/presets.js +6 -4
  119. package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -1
  120. package/lib/types/dsh-adapter/providerWizard.js +6 -2
  121. package/lib/types/dsh-adapter/subagents.d.ts +1 -0
  122. package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
  123. package/lib/types/dsh-adapter/subagents.js +10 -1
  124. package/lib/types/i18n.d.ts +174 -18
  125. package/lib/types/i18n.d.ts.map +1 -1
  126. package/lib/types/i18n.js +53 -11
  127. package/lib/types/ink/components/App.d.ts.map +1 -1
  128. package/lib/types/ink/components/App.js +10 -1
  129. package/lib/types/ink/parse-keypress.d.ts +35 -0
  130. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  131. package/lib/types/ink/parse-keypress.js +611 -83
  132. package/lib/types/ink/terminal-querier.d.ts +25 -0
  133. package/lib/types/ink/terminal-querier.d.ts.map +1 -1
  134. package/lib/types/ink/terminal-querier.js +42 -0
  135. package/lib/types/ink/update-overflow-guard.d.ts +24 -0
  136. package/lib/types/ink/update-overflow-guard.d.ts.map +1 -1
  137. package/lib/types/ink/update-overflow-guard.js +25 -3
  138. package/lib/types/minimalUiMode.d.ts +19 -0
  139. package/lib/types/minimalUiMode.d.ts.map +1 -0
  140. package/lib/types/minimalUiMode.js +23 -0
  141. package/lib/types/oauth.d.ts +5 -10
  142. package/lib/types/oauth.d.ts.map +1 -1
  143. package/lib/types/oauth.js +5 -10
  144. package/lib/types/screens/Chat.d.ts +4 -1
  145. package/lib/types/screens/Chat.d.ts.map +1 -1
  146. package/lib/types/screens/Chat.js +27 -7
  147. package/lib/types/screens/StatusLine.d.ts +4 -0
  148. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  149. package/lib/types/screens/StatusLine.js +12 -12
  150. package/lib/types/settings/definitions.js +4 -4
  151. package/lib/types/tips.d.ts.map +1 -1
  152. package/lib/types/tips.js +10 -4
  153. package/lib/types/update.js +2 -2
  154. package/lib/types/utils/pastedImagePath.d.ts +8 -0
  155. package/lib/types/utils/pastedImagePath.d.ts.map +1 -1
  156. package/lib/types/utils/pastedImagePath.js +29 -5
  157. package/package.json +111 -100
  158. package/presets/liangshen/agent.cordis.yml +4 -1
  159. package/presets/liangshen/preset.yml +1 -1
  160. package/lib/types/minimalMode.d.ts +0 -13
  161. package/lib/types/minimalMode.d.ts.map +0 -1
  162. package/lib/types/minimalMode.js +0 -17
  163. package/node_modules/@deepseek-harness-tui/dsh-auth/LICENSE +0 -21
  164. package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +0 -143
  165. package/node_modules/@deepseek-harness-tui/dsh-auth/cordis.patch.yml +0 -11
  166. package/node_modules/@deepseek-harness-tui/dsh-auth/dsh-plugin.json +0 -44
  167. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.js +0 -193
  168. package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +0 -69
@@ -5,6 +5,7 @@
5
5
  * then interprets sequences as keypresses.
6
6
  */
7
7
  import { Buffer } from 'buffer';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { isCSIFinal, isCSIIntermediate, isCSIParam, PASTE_END, PASTE_START } from './termio/csi.js';
9
10
  import { createTokenizer } from './termio/tokenize.js';
10
11
  // eslint-disable-next-line no-control-regex
@@ -85,6 +86,14 @@ const SGR_MOUSE_TAIL_PREFIX_RE = /^\[<\d+;\d+;\d+[Mm]/;
85
86
  // the deadline bounds this ambiguity. It runs on every call so continuous
86
87
  // input cannot starve it into a de-facto immortal hold.
87
88
  const MOUSE_TAIL_HOLD_GRACE_MS = 1000;
89
+ // How long an unfinished terminal-reply prefix may be held for its
90
+ // continuation, and how many bytes of it may accumulate. Same caliber as the
91
+ // win32 record hold above: measured from FIRST capture (later flushes must not
92
+ // renew it), deadline checked on every parse call, and an over-limit or
93
+ // expired hold is released by tokenizer.reset() — protocol bytes are dropped
94
+ // rather than leaked into the prompt.
95
+ const TERMINAL_RESPONSE_TAIL_GRACE_MS = 1000;
96
+ const TERMINAL_RESPONSE_MAX_LENGTH = 64;
88
97
  // dwControlKeyState modifier bits (others — NUMLOCK_ON 0x20, CAPSLOCK_ON
89
98
  // 0x80, ENHANCED_KEY 0x100 — are state indicators, not pressed modifiers)
90
99
  const WIN32_CS_ALT = 0x01 | 0x02;
@@ -176,7 +185,54 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s;
176
185
  // Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
177
186
  // eslint-disable-next-line no-control-regex
178
187
  const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
188
+ /**
189
+ * Terminal control sequences that must never survive into a paste payload.
190
+ *
191
+ * A desktop file DROP reaches the TUI as terminal bytes, not as text: Windows
192
+ * Terminal / OpenConsole hand a dropped path over as an OSC 8 hyperlink
193
+ * (`ESC ] 8 ; <params> ; file:///… ST`), and a `DECSET 9001` (win32-input-mode)
194
+ * host additionally decomposes that payload into per-character key records
195
+ * (see the decomposed-paste matcher below). The hyperlink itself is RESTORED
196
+ * to its local path before this hygiene runs (see `createPasteKey` and the
197
+ * OSC 8 block below); what is stripped here is the rest of the protocol
198
+ * shapes a paste payload can carry.
199
+ *
200
+ * Scope (DESIGN D3, narrowed by measurement): COMPLETE OSC sequences only.
201
+ * CSI sequences, bare ESC bytes and C0 control bytes are deliberately KEPT —
202
+ * a bracketed paste's payload is user data. PR #1142 pins that protocol-shaped
203
+ * literal text (`ESC[1;2;3;1A`) survives byte-for-byte
204
+ * (`scripts/verify-win32-input.tsx`), and the whole C0 band belongs to the
205
+ * COMPOSER: every single-line paste ingress flattens C0/C1 to a SPACE
206
+ * (`flattenPasteInline`), so a C0 byte dropped here does not sanitize
207
+ * anything — it deletes the space the composer owes
208
+ * (`scripts/verify-question-paste.tsx` 1a/1c pin that flattening, TAB/CR/LF
209
+ * and DEL included). The wide strip (CSI + residual ESC) proved mutually
210
+ * exclusive with the #1142 contract.
211
+ */
212
+ // eslint-disable-next-line no-control-regex -- deliberate: paste payloads carry terminal sequences
213
+ const OSC_IN_PASTE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/gu;
214
+ /**
215
+ * Strip the protocol frames one paste payload can carry — see
216
+ * {@link OSC_IN_PASTE} for the exact scope.
217
+ *
218
+ * This is the LAST step of the paste choke point, and it is deliberately the
219
+ * only one: no C0/C1 byte is removed here, because removal is lossy where the
220
+ * composer is not (it flattens those bytes to spaces). The registration that
221
+ * briefly added an inert-C0 strip here was the CI regression that
222
+ * `scripts/verify-question-paste.tsx` 1a/1c caught; `verify-paste-drop`
223
+ * re-pins the surviving side of that contract.
224
+ */
225
+ function cleanPastePayload(content) {
226
+ return content.replace(OSC_IN_PASTE, '');
227
+ }
179
228
  function createPasteKey(content) {
229
+ // Order is a contract (D6/D1): a complete OSC 8 drop frame is RESTORED to
230
+ // its local path BEFORE the payload hygiene — hygiene strips the very OSC
231
+ // frame the drop is encoded in. Every paste path goes through this ONE
232
+ // choke point (VT bracketed paste, the decomposed win32 paste, and both
233
+ // flush paths), so no caller has to remember the order.
234
+ const dropPath = osc8DropPath(content);
235
+ const text = dropPath === null ? cleanPastePayload(content) : pastePayloadForPath(dropPath);
180
236
  return {
181
237
  kind: 'key',
182
238
  name: '',
@@ -186,11 +242,143 @@ function createPasteKey(content) {
186
242
  shift: false,
187
243
  option: false,
188
244
  super: false,
189
- sequence: content,
190
- raw: content,
245
+ sequence: text,
246
+ raw: text,
191
247
  isPasted: true,
192
248
  };
193
249
  }
250
+ // -- OSC 8 drop frames -------------------------------------------------------
251
+ //
252
+ // Windows hands a dropped file over as an OSC 8 hyperlink:
253
+ //
254
+ // ESC ] 8 ; <params> ; <URI> ( BEL | ST )
255
+ //
256
+ // `params` is the hyperlink attribute list (`id=16:42`, or empty for the form
257
+ // this TUI itself emits) and the URI is everything after the SECOND `;` — a
258
+ // URI may legally contain `;`, params may not.
259
+ //
260
+ // A frame is claimed in the fixed decision order (D6) by
261
+ // `claimProtocolSequence`, and — when the frame set was already reassembled
262
+ // into one paste body (win32 decomposed paste / VT bracketed paste) — by
263
+ // `createPasteKey` before the payload hygiene runs.
264
+ /** OSC 8 introducer: `ESC ] 8 ;`. */
265
+ const OSC8_FRAME_PREFIX = '\x1b]8;';
266
+ /**
267
+ * Longest OSC 8 frame the record accumulator will hold. A drop URI is a local
268
+ * path plus percent-encoding, and the composer's own pasted-path cap is 4096
269
+ * chars (`MAX_PASTED_PATH_CHARS`), so anything beyond this cannot become a
270
+ * usable path and must not extend a hold indefinitely.
271
+ */
272
+ const OSC8_FRAME_MAX_LENGTH = 4096;
273
+ /**
274
+ * Read one OSC 8 frame at `offset`. Returns null when the bytes there are not
275
+ * an OSC 8 frame, or when the URI is followed by anything other than a frame
276
+ * terminator (BEL 0x07 / ST `ESC \`) or the start of the next frame.
277
+ *
278
+ * The "next frame" shape matters because the win32 record translator drops
279
+ * BEL outright (a synthesized Uc=7 record carries no key meaning), so a
280
+ * decomposed drop can lose its BEL terminator before the payload is
281
+ * reassembled. A lone frame with neither a terminator nor a successor stays
282
+ * unclaimed: the tail of a truncated stream must not be read as a path.
283
+ */
284
+ function readOsc8Frame(payload, offset) {
285
+ if (!payload.startsWith(OSC8_FRAME_PREFIX, offset))
286
+ return null;
287
+ const paramsEnd = payload.indexOf(';', offset + OSC8_FRAME_PREFIX.length);
288
+ if (paramsEnd === -1)
289
+ return null;
290
+ const uriStart = paramsEnd + 1;
291
+ let uriEnd = uriStart;
292
+ while (uriEnd < payload.length && payload[uriEnd] !== '\u0007' && payload[uriEnd] !== '\x1b') {
293
+ uriEnd++;
294
+ }
295
+ const uri = payload.slice(uriStart, uriEnd);
296
+ if (payload[uriEnd] === '\u0007')
297
+ return { uri, end: uriEnd + 1 };
298
+ if (payload.startsWith('\x1b\\', uriEnd))
299
+ return { uri, end: uriEnd + 2 };
300
+ if (payload.startsWith(OSC8_FRAME_PREFIX, uriEnd))
301
+ return { uri, end: uriEnd };
302
+ return null;
303
+ }
304
+ /**
305
+ * The local path a payload restores to, or null when the payload is NOT a
306
+ * pure OSC 8 frame set or its URI is not a decodable LOCAL file URL. Called
307
+ * with one complete sequence (the decision chain) or with a whole reassembled
308
+ * paste body (the choke point).
309
+ *
310
+ * Fail-closed rules (DESIGN D4 / AC-4):
311
+ * - only `file://` URIs are restored — other schemes fall through to the
312
+ * normal response/prose handling;
313
+ * - a URI carrying whitespace is multiple tokens, not one path, and is
314
+ * refused (the same conservative contract as `parsePastedImagePath`);
315
+ * - more than one distinct URI is a multi-file drop, which v1 does not
316
+ * cover: fail closed rather than silently keep only the first file;
317
+ * - a remote authority (`file://server/share/…`) or a decode failure is
318
+ * never silently reinterpreted as a local path;
319
+ * - a payload with any trailing non-OSC-8 bytes is NOT a drop and is left to
320
+ * the hygiene path, so prose that merely quotes a link keeps its text.
321
+ */
322
+ function osc8DropPath(payload) {
323
+ let offset = 0;
324
+ let uri;
325
+ while (offset < payload.length) {
326
+ const frame = readOsc8Frame(payload, offset);
327
+ if (frame === null || frame.end <= offset)
328
+ return null;
329
+ if (frame.uri !== '') {
330
+ if (uri !== undefined && uri !== frame.uri)
331
+ return null;
332
+ uri = frame.uri;
333
+ }
334
+ offset = frame.end;
335
+ }
336
+ if (uri === undefined || /\s/u.test(uri))
337
+ return null;
338
+ return localPathOfFileUri(uri);
339
+ }
340
+ /**
341
+ * `file://` URI → local path (percent-escapes decoded by `fileURLToPath`), or
342
+ * null when the URI is not a local file URL or decoding fails.
343
+ */
344
+ function localPathOfFileUri(uri) {
345
+ if (!/^file:\/\//iu.test(uri))
346
+ return null;
347
+ let url;
348
+ try {
349
+ url = new URL(uri);
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ // A non-empty authority is a remote share (UNC): refuse it instead of
355
+ // letting `fileURLToPath` reinterpret it as a local path on hosts that
356
+ // ignore the authority. `localhost` is this machine.
357
+ if (url.host !== '' && url.host !== 'localhost')
358
+ return null;
359
+ try {
360
+ return fileURLToPath(url);
361
+ }
362
+ catch {
363
+ return null;
364
+ }
365
+ }
366
+ /**
367
+ * The paste payload for a restored drop path. `parsePastedImagePath` accepts
368
+ * exactly one whitespace-free token, or the whole payload quoted — and a drop
369
+ * is ONE unambiguous file, so a path containing whitespace is handed over in
370
+ * the quoted single-token form the composer already documents
371
+ * (`@"my dir/a.ts"` is the mention spelling of the same shape). A path no
372
+ * quote can represent (a literal `"`) stays bare: the composer then inserts
373
+ * it verbatim, which is the pre-existing fail-closed behaviour for ambiguous
374
+ * tokens. Windows paths cannot contain `"`, so on this platform the quoted
375
+ * form is exactly the decoded path.
376
+ */
377
+ function pastePayloadForPath(path) {
378
+ if (!/[\s"]/u.test(path))
379
+ return path;
380
+ return path.includes('"') ? path : `"${path}"`;
381
+ }
194
382
  /** DECRPM status values (response to DECRQM) */
195
383
  export const DECRPM_STATUS = {
196
384
  NOT_RECOGNIZED: 0,
@@ -199,6 +387,23 @@ export const DECRPM_STATUS = {
199
387
  PERMANENTLY_SET: 3,
200
388
  PERMANENTLY_RESET: 4,
201
389
  };
390
+ // Unfinished prefixes of every reply shape parseTerminalResponse() knows.
391
+ // MUST be kept in sync with the pattern list above: a reply type added there
392
+ // needs an alternative here too, or a ConPTY-split tail of that shape is
393
+ // released at the flush and leaks into the prompt as literal text.
394
+ // ESC [ ? … DA1 / kitty flags / DECXCPR / DECRPM
395
+ // ESC [ ? … $ DECRPM's `$y` intermediate byte
396
+ // ESC [ > … DA2
397
+ // ESC [ digits [; digits] DSR (row;col) and XTWINOPS pixel-size replies
398
+ // ESC [ … $ a `$`-intermediate CSI awaiting its final byte
399
+ // ESC P > | … XTVERSION (DCS)
400
+ // Only PREFIXES are matched; completeness is always decided by
401
+ // parseTerminalResponse(), never by this regex. Introducer-only prefixes
402
+ // (`ESC [`, `ESC P`) are deliberately excluded: they are indistinguishable
403
+ // from ordinary key input, and holding the bare `ESC [` swallowed
404
+ // `ESC[`+letter input in the #1073 review.
405
+ // eslint-disable-next-line no-control-regex
406
+ const TERMINAL_RESPONSE_PREFIX_RE = /^(?:\x1b\[\?[\d;]*\$?|\x1b\[>[\d;]*|\x1b\[[\d;]*\$|\x1b\[\d+(?:;\d+)*|\x1bP>\|)/;
202
407
  /**
203
408
  * Try to recognize a sequence token as a terminal response.
204
409
  * Returns null if the sequence is not a known response pattern
@@ -277,6 +482,90 @@ function parseTerminalResponse(s) {
277
482
  }
278
483
  return null;
279
484
  }
485
+ // Unfinished prefixes of the reply shapes parseTerminalResponse() accepts,
486
+ // paired with the response type they can still complete into. This is the
487
+ // shape half of the claim gate: a held reply fragment is only joined to its
488
+ // continuation while one of these types is actually expected by a pending
489
+ // query (terminalExpectedResponseTypes). The patterns are deliberately
490
+ // tighter than TERMINAL_RESPONSE_PREFIX_RE: `?` + two params can still grow
491
+ // into a DA1 but never into a DECRPM (`?mode;status$y` has exactly two
492
+ // params), so a DA1-shaped fragment is released as literal text as soon as
493
+ // only DECRPM is in flight. `parseTerminalResponse` remains the only
494
+ // authority on completeness.
495
+ // eslint-disable-next-line no-control-regex
496
+ const INCOMPLETE_RESPONSE_PREFIXES = [
497
+ // DA1: any `?`-parameter list (including the bare introducer).
498
+ ['da1', /^\x1b\[\?[\d;]*$/],
499
+ // DECRPM: `?mode;status` before the `$` intermediate, then the same before
500
+ // `$` + final.
501
+ ['decrpm', /^\x1b\[\?\d*(?:;\d*)?$/],
502
+ ['decrpm', /^\x1b\[\?\d+;\d+\$$/],
503
+ // Kitty flags and DECXCPR: one `?` + at most two numeric params.
504
+ ['kittyKeyboard', /^\x1b\[\?\d*$/],
505
+ ['cursorPosition', /^\x1b\[\?\d*(?:;\d*)?$/],
506
+ // DA2: any `>`-parameter list.
507
+ ['da2', /^\x1b\[>[\d;]*$/],
508
+ // XTWINOPS: `6;h;w t` / `4;h;w t`, up to the final byte.
509
+ ['terminalPixelSize', /^\x1b\[[46](?:;\d*){0,2}$/],
510
+ // XTVERSION: `P>` can still gain `|` and a payload; ST's ESC may flush.
511
+ ['xtversion', /^\x1bP>(?:\|[^\x1b]*(?:\x1b)?)?$/],
512
+ ];
513
+ /**
514
+ * Response types an unfinished reply-shaped candidate could still complete
515
+ * into, judged from its introducer and parameter bytes alone. Empty means
516
+ * the bytes cannot become a known reply (either a final byte already ended a
517
+ * different sequence, or the shape is unknown) and must stay literal.
518
+ * @param candidate - the inbound bytes, with the ESC introducer synthesized.
519
+ * @returns the types a continuation could still produce.
520
+ */
521
+ function possibleResponseTypes(candidate) {
522
+ const types = new Set();
523
+ for (const [type, pattern] of INCOMPLETE_RESPONSE_PREFIXES) {
524
+ if (pattern.test(candidate))
525
+ types.add(type);
526
+ }
527
+ return types;
528
+ }
529
+ /**
530
+ * Whether an unfinished candidate can still become a response the host is
531
+ * currently waiting for. Both halves are required: an in-flight query of the
532
+ * matching type, and a shape that can still produce it.
533
+ */
534
+ function expectsResponseType(candidate, expected) {
535
+ for (const type of possibleResponseTypes(candidate)) {
536
+ if (expected.has(type))
537
+ return true;
538
+ }
539
+ return false;
540
+ }
541
+ /**
542
+ * Find the shortest complete terminal response at the head of a post-flush
543
+ * tail whose introducer ESC was already flushed as a key. The caller
544
+ * synthesizes the ESC back; `parseTerminalResponse` decides completeness, so
545
+ * only a type the host expects is claimed and trailing bytes are left for
546
+ * ordinary parsing.
547
+ * @param tail - fragment without its ESC introducer.
548
+ * @param expected - response types of the queries still awaiting a reply.
549
+ * @returns the claimed response and how many bytes of `tail` it consumed, or
550
+ * undefined when no expected reply completes at the head.
551
+ */
552
+ function claimExpectedResponseHead(tail, expected) {
553
+ const limit = Math.min(tail.length, TERMINAL_RESPONSE_MAX_LENGTH);
554
+ for (let consumed = 2; consumed <= limit; consumed++) {
555
+ const head = '\x1b' + tail.slice(0, consumed);
556
+ const response = parseTerminalResponse(head);
557
+ if (response) {
558
+ return expected.has(response.type)
559
+ ? { response, consumed }
560
+ : undefined;
561
+ }
562
+ // A head that can no longer become an expected reply ends the scan:
563
+ // longer candidates extend a shape that is already closed or unknown.
564
+ if (!expectsResponseType(head, expected))
565
+ return undefined;
566
+ }
567
+ return undefined;
568
+ }
280
569
  function splitNumericParams(params) {
281
570
  if (!params)
282
571
  return [];
@@ -630,50 +919,85 @@ function synthesizedWin32Char(key) {
630
919
  }
631
920
  return win32RecordChar(key);
632
921
  }
633
- function parseReassembledWin32Protocol(sequence) {
922
+ /**
923
+ * The ONE decision chain for a complete reassembled terminal sequence. The
924
+ * order is a contract (DESIGN D6) and must not be permuted:
925
+ *
926
+ * 1. OSC 8 drop frame — a dropped file's hyperlink. It must win over the
927
+ * OSC response shape in step 2, which also matches `ESC ] 8 ; …` and
928
+ * used to swallow the whole link as a bogus terminal reply (#1067).
929
+ * 2. terminal response — DECRPM/DA/OSC replies to our own queries
930
+ * (#1177's type-bound claiming).
931
+ * 3. mouse report — SGR (1006), with the X10 report as the legacy
932
+ * compatibility fallback.
933
+ *
934
+ * A sequence that falls through all three is a record body or a keypress, and
935
+ * only the caller can tell which (`parseReassembledWin32Protocol` for records,
936
+ * the token loop for VT input), so that dispatch stays with the caller.
937
+ */
938
+ function claimProtocolSequence(sequence) {
939
+ const dropPath = osc8DropPath(sequence);
940
+ if (dropPath !== null) {
941
+ // Hand the local path to the ONE paste choke point (D2), so the existing
942
+ // image stage / `@` reference pipeline sees a normal paste.
943
+ return createPasteKey(pastePayloadForPath(dropPath));
944
+ }
634
945
  const response = parseTerminalResponse(sequence);
635
946
  if (response)
636
947
  return { kind: 'response', sequence, response };
637
- const mouse = parseMouseEvent(sequence) ?? parseX10MouseEvent(sequence);
638
- if (mouse)
639
- return mouse;
948
+ return parseMouseEvent(sequence) ?? parseX10MouseEvent(sequence);
949
+ }
950
+ function parseReassembledWin32Protocol(sequence) {
951
+ // Fixed decision order (D6) — see claimProtocolSequence.
952
+ const claimed = claimProtocolSequence(sequence);
953
+ if (claimed)
954
+ return claimed;
640
955
  if (SGR_MOUSE_RE.test(sequence) || (sequence.length === 6 && sequence.startsWith('\x1b[M'))) {
641
956
  return parseKeypress(sequence);
642
957
  }
643
958
  return null;
644
959
  }
645
- function feedWin32Protocol(state, key) {
646
- const ch = synthesizedWin32Char(key);
647
- if (state.held.length === 0) {
648
- if (ch !== '\x1b')
649
- return [key];
650
- state.held.push(key);
651
- state.sequence = ch;
652
- return [];
653
- }
654
- if (ch === undefined) {
655
- const held = state.held;
656
- state.held = [];
657
- state.sequence = '';
658
- return [...held, ...feedWin32Protocol(state, key)];
659
- }
660
- state.held.push(key);
661
- state.sequence += ch;
662
- const sequence = state.sequence;
960
+ /** Release every key held for the current candidate sequence. */
961
+ function releaseWin32ProtocolHold(state) {
962
+ const held = state.held;
963
+ state.held = [];
964
+ state.sequence = '';
965
+ return held;
966
+ }
967
+ /**
968
+ * Resolve one accumulated protocol candidate. An array is the dispatch result
969
+ * (empty while the frame is still incomplete); null means the bytes are not a
970
+ * protocol shape we may hold, and the caller releases its keys.
971
+ *
972
+ * Order and bounds are the existing contract (#1142/#1177) with the OSC 8
973
+ * drop frame (D1) added FIRST: under win32-input-mode a drop can arrive as
974
+ * per-character synthesized records without bracketed-paste markers, so the
975
+ * hyperlink has to be reassembled here, ahead of any response/mouse claim.
976
+ * Holding past the introducer also keeps it away from
977
+ * `parseTerminalResponse`, whose OSC shape would otherwise take
978
+ * `ESC ] 8 ; …` as a bogus reply and swallow the dropped path (#1067).
979
+ * The OSC terminator is BEL or ST with its own length cap: a BEL record has
980
+ * no key meaning and is dropped by the translator, so a decomposed frame can
981
+ * lose its BEL — such a frame is released by the flush bound like any other
982
+ * abandoned sequence.
983
+ */
984
+ function resolveWin32ProtocolCandidate(state, sequence, ch) {
663
985
  if (sequence === '\x1b' || sequence === '\x1b[')
664
986
  return [];
665
- if (!sequence.startsWith('\x1b[') || sequence.length > 64) {
666
- const held = state.held;
667
- state.held = [];
668
- state.sequence = '';
669
- return held;
987
+ if (sequence.startsWith('\x1b]')) {
988
+ const terminated = sequence.endsWith('\u0007') || sequence.endsWith('\x1b\\');
989
+ if (!terminated)
990
+ return sequence.length <= OSC8_FRAME_MAX_LENGTH ? [] : null;
991
+ const held = releaseWin32ProtocolHold(state);
992
+ const parsed = parseReassembledWin32Protocol(sequence);
993
+ return parsed ? [parsed] : held;
670
994
  }
995
+ if (!sequence.startsWith('\x1b[') || sequence.length > 64)
996
+ return null;
671
997
  if (sequence.startsWith('\x1b[M')) {
672
998
  if (sequence.length < 6)
673
999
  return [];
674
- const held = state.held;
675
- state.held = [];
676
- state.sequence = '';
1000
+ const held = releaseWin32ProtocolHold(state);
677
1001
  const parsed = sequence.length === 6
678
1002
  ? parseReassembledWin32Protocol(sequence)
679
1003
  : null;
@@ -683,16 +1007,29 @@ function feedWin32Protocol(state, key) {
683
1007
  if ((code >= 0x20 && code <= 0x3f))
684
1008
  return [];
685
1009
  if (code >= 0x40 && code <= 0x7e) {
686
- const held = state.held;
687
- state.held = [];
688
- state.sequence = '';
1010
+ const held = releaseWin32ProtocolHold(state);
689
1011
  const parsed = parseReassembledWin32Protocol(sequence);
690
1012
  return parsed ? [parsed] : held;
691
1013
  }
692
- const held = state.held;
693
- state.held = [];
694
- state.sequence = '';
695
- return held;
1014
+ return null;
1015
+ }
1016
+ function feedWin32Protocol(state, key) {
1017
+ const ch = synthesizedWin32Char(key);
1018
+ if (state.held.length === 0) {
1019
+ if (ch !== '\x1b')
1020
+ return [key];
1021
+ state.held.push(key);
1022
+ state.sequence = ch;
1023
+ return [];
1024
+ }
1025
+ if (ch === undefined) {
1026
+ const held = releaseWin32ProtocolHold(state);
1027
+ return [...held, ...feedWin32Protocol(state, key)];
1028
+ }
1029
+ state.held.push(key);
1030
+ state.sequence += ch;
1031
+ return (resolveWin32ProtocolCandidate(state, state.sequence, ch) ??
1032
+ releaseWin32ProtocolHold(state));
696
1033
  }
697
1034
  function feedWin32Input(pasteState, protocolState, key) {
698
1035
  const inputs = [];
@@ -762,6 +1099,22 @@ export function parseMultipleKeypresses(prevState, input = '') {
762
1099
  const win32HoldAllowed = (prevState.win32Capable ?? true) && win32InputMode;
763
1100
  const isRecordPrefix = (value) => !inPaste && WIN32_INPUT_PREFIX_RE.test(value) &&
764
1101
  (WIN32_INPUT_BODY_PREFIX_RE.test(value) || win32HoldAllowed);
1102
+ // Evidence is the live query lifecycle, not a recency window: the host
1103
+ // injects the response types of the queries still awaiting an answer, and
1104
+ // a reply is only ever claimed when its shape can complete into one of
1105
+ // them. Read-only here and re-read on every call, so a query that settles
1106
+ // (matched reply, sentinel drain, dispose) stops authorizing immediately.
1107
+ const expectedResponseTypes = new Set(prevState.terminalExpectedResponseTypes ?? []);
1108
+ const hasQueryEvidence = expectedResponseTypes.size > 0;
1109
+ // A buffer that is already reply-specific (TERMINAL_RESPONSE_PREFIX_RE) is
1110
+ // eligible to be held open for its continuation — but only with the
1111
+ // host-injected in-flight-query evidence (D1) AND a shape/type match (D2).
1112
+ // Record framing is judged first (D6): every numeric CSI prefix the record
1113
+ // branch can frame belongs to it, and this predicate never re-labels one.
1114
+ // Without evidence nothing is held, so a direct caller that never injects
1115
+ // it keeps the pre-gate behavior (D7 / AC-3). Paste payloads stay literal,
1116
+ // exactly like the record branch above.
1117
+ const isResponsePrefix = (value) => !isRecordPrefix(value) && !inPaste && TERMINAL_RESPONSE_PREFIX_RE.test(value);
765
1118
  if (win32InputStartedAt !== undefined && now - win32InputStartedAt >= WIN32_INPUT_GRACE_MS) {
766
1119
  tokenizer.reset();
767
1120
  win32InputStartedAt = undefined;
@@ -769,6 +1122,17 @@ export function parseMultipleKeypresses(prevState, input = '') {
769
1122
  if (win32EscFlushedAt !== undefined && now - win32EscFlushedAt >= WIN32_INPUT_GRACE_MS) {
770
1123
  win32EscFlushedAt = undefined;
771
1124
  }
1125
+ // Same deadline discipline for the reply-prefix hold: first capture only,
1126
+ // checked on every call (not just on flush, so continuous input cannot
1127
+ // starve it), and released by resetting the tokenizer — bytes dropped, the
1128
+ // same caliber as the win32 record hold, never leaked into the prompt.
1129
+ let terminalResponseHoldAt = prevState.terminalResponseHoldAt;
1130
+ if (terminalResponseHoldAt !== undefined &&
1131
+ now - terminalResponseHoldAt >= TERMINAL_RESPONSE_TAIL_GRACE_MS) {
1132
+ if (isResponsePrefix(tokenizer.buffer()))
1133
+ tokenizer.reset();
1134
+ terminalResponseHoldAt = undefined;
1135
+ }
772
1136
  if (inputString && win32EscFlushedAt !== undefined) {
773
1137
  // Only the immediate continuation of an actual ESC flush may acquire
774
1138
  // a missing introducer. Never capture arbitrary '['-led user text.
@@ -796,13 +1160,49 @@ export function parseMultipleKeypresses(prevState, input = '') {
796
1160
  inputString = inputString.slice(continuation.length);
797
1161
  }
798
1162
  }
799
- let deferFlush = isFlush && isRecordPrefix(tokenizer.buffer());
800
- if (deferFlush && tokenizer.buffer().length > WIN32_INPUT_MAX_LENGTH) {
1163
+ // Two independent reasons to keep the tokenizer's buffer across a flush:
1164
+ // the #1142 record frame (win32), and an unfinished reply prefix when the
1165
+ // host reports a query in flight (D1 × D2). Each keeps its own bound (D6);
1166
+ // an over-limit buffer is dropped exactly like #1142 drops an oversized
1167
+ // record frame.
1168
+ const recordPrefix = isRecordPrefix(tokenizer.buffer());
1169
+ // Evidence is re-read on every call, never latched: once the host stops
1170
+ // reporting a query in flight — or the buffer's shape can no longer
1171
+ // complete into a response type it expects — the claim ends. A captured
1172
+ // hold is then dropped by the release branch below instead of being
1173
+ // flushed into the body (no evidence, no claim).
1174
+ const claimsResponsePrefix = hasQueryEvidence &&
1175
+ isResponsePrefix(tokenizer.buffer()) &&
1176
+ expectsResponseType(tokenizer.buffer(), expectedResponseTypes);
1177
+ let deferFlush = isFlush && (recordPrefix || claimsResponsePrefix);
1178
+ if (deferFlush &&
1179
+ tokenizer.buffer().length >
1180
+ (recordPrefix ? WIN32_INPUT_MAX_LENGTH : TERMINAL_RESPONSE_MAX_LENGTH)) {
801
1181
  tokenizer.reset();
802
1182
  deferFlush = false;
803
1183
  }
1184
+ // First capture only: later flushes (App re-arms its timer while
1185
+ // `incomplete` is set) must not renew the deadline.
1186
+ if (deferFlush && claimsResponsePrefix)
1187
+ terminalResponseHoldAt ??= now;
1188
+ // A captured hold can lose its authorization before the flush: the host
1189
+ // re-injects the pending set on every call, so a query that settles (or a
1190
+ // shape that evolves out of the expected types) turns claimsResponsePrefix
1191
+ // false. Falling back to tokenizer.flush() there would hand the buffered
1192
+ // reply prefix to parseKeypress() as a key/literal text. Drop it like the
1193
+ // expiry path instead and end the hold. Both the captured timestamp and
1194
+ // the reply shape are required: bytes that were never held keep the
1195
+ // pre-gate release semantics (AC-3).
1196
+ const releaseResponseHold = isFlush &&
1197
+ terminalResponseHoldAt !== undefined &&
1198
+ isResponsePrefix(tokenizer.buffer()) &&
1199
+ !claimsResponsePrefix;
1200
+ if (releaseResponseHold) {
1201
+ tokenizer.reset();
1202
+ terminalResponseHoldAt = undefined;
1203
+ }
804
1204
  const tokens = isFlush
805
- ? deferFlush ? [] : tokenizer.flush()
1205
+ ? deferFlush || releaseResponseHold ? [] : tokenizer.flush()
806
1206
  : tokenizer.feed(inputString);
807
1207
  if (isFlush && !inPaste && tokens.some(token => token.value === '\x1b')) {
808
1208
  win32EscFlushedAt = now;
@@ -839,6 +1239,37 @@ export function parseMultipleKeypresses(prevState, input = '') {
839
1239
  // App's 50ms flush timer, so a per-call flag would still let the SECOND
840
1240
  // quiet flush kill a press split by >~100ms (observed over SSH).
841
1241
  let mouseTailHoldAt = prevState.mouseTailHoldAt;
1242
+ const terminalResponseTailAfterEscFlushAt = prevState.terminalResponseTailAfterEscFlushAt;
1243
+ // Fragment of a reply whose introducer ESC was already flushed as a key,
1244
+ // held across calls until the rest of its shape arrives (see
1245
+ // KeyParseState.terminalResponseReattachTail).
1246
+ let terminalResponseReattachTail = prevState.terminalResponseReattachTail;
1247
+ // Re-attach (D3b, handed over from #796): a lone Escape was genuinely
1248
+ // flushed, so a text token inside the window may be the rest of a reply
1249
+ // that lost its introducer. Claim it only inside the bounded window AND
1250
+ // with a query in flight whose expected response type the shape can still
1251
+ // complete into — the missing gate CodeRabbit flagged on #796.
1252
+ // parseTerminalResponse() remains the completeness authority; bytes whose
1253
+ // shape matches no expected type stay literal, and without injected
1254
+ // evidence this branch never fires (AC-3).
1255
+ // Bounded by TIME, not by "the next call": a burst can interleave a chunk
1256
+ // between the flush and the tail (another reply answering an earlier query,
1257
+ // or a keystroke), and the real-ConPTY dry run leaked the tail when that
1258
+ // chunk closed the window early.
1259
+ const mayRecoverTerminalResponseTail = hasQueryEvidence &&
1260
+ terminalResponseTailAfterEscFlushAt !== undefined &&
1261
+ Date.now() - terminalResponseTailAfterEscFlushAt <= TERMINAL_RESPONSE_TAIL_GRACE_MS;
1262
+ // Carry that stamp under the same discipline while it is still in-window,
1263
+ // and drop it once it expires (or the evidence is gone) so no stale window
1264
+ // survives — the #1142 record window above carries its own stamp the same way.
1265
+ const carryTailAfterEscFlush = mayRecoverTerminalResponseTail
1266
+ ? terminalResponseTailAfterEscFlushAt
1267
+ : undefined;
1268
+ // A closed window also drops the fragment it was holding: those bytes
1269
+ // cannot be joined to a reply any more, and protocol-shaped bytes are
1270
+ // dropped rather than shown.
1271
+ if (carryTailAfterEscFlush === undefined)
1272
+ terminalResponseReattachTail = undefined;
842
1273
  // Hard deadline, checked at the top of EVERY call — not only on flush.
843
1274
  // Continuous input keeps cancelling and re-arming App's 50ms flush timer,
844
1275
  // so a flush-only check can starve while the user types: a hold past its
@@ -861,6 +1292,35 @@ export function parseMultipleKeypresses(prevState, input = '') {
861
1292
  for (let qi = 0; qi < tokenQueue.length; qi++) {
862
1293
  const token = tokenQueue[qi];
863
1294
  if (token.type === 'sequence') {
1295
+ // Once the DCS introducer ESC has timed out, the tokenizer emits an
1296
+ // XTVERSION tail as text but its ST/BEL terminator as a sequence. Join
1297
+ // that terminator before treating it as an unrelated key. A lone ST
1298
+ // introducer ESC may itself time out before the trailing backslash.
1299
+ const heldTail = terminalResponseReattachTail;
1300
+ if (heldTail !== undefined &&
1301
+ heldTail.startsWith('P>|') &&
1302
+ mayRecoverTerminalResponseTail &&
1303
+ expectedResponseTypes.has('xtversion')) {
1304
+ if (token.value === '\x1b' && heldTail.length + 3 <= TERMINAL_RESPONSE_MAX_LENGTH) {
1305
+ terminalResponseReattachTail = heldTail + '\x1b';
1306
+ win32EscFlushedAt = undefined;
1307
+ continue;
1308
+ }
1309
+ if (token.value === '\x1b\\' || token.value === '\x07') {
1310
+ const sequence = '\x1b' + heldTail + token.value;
1311
+ const response = sequence.length <= TERMINAL_RESPONSE_MAX_LENGTH
1312
+ ? parseTerminalResponse(sequence)
1313
+ : null;
1314
+ if (response?.type === 'xtversion') {
1315
+ terminalResponseReattachTail = undefined;
1316
+ keys.push({ kind: 'response', sequence, response });
1317
+ continue;
1318
+ }
1319
+ }
1320
+ }
1321
+ // An unrelated sequence between the held fragment and its terminator
1322
+ // proves the reply died; do not merge it with later input.
1323
+ terminalResponseReattachTail = undefined;
864
1324
  if (token.value === PASTE_START) {
865
1325
  inPaste = true;
866
1326
  pasteBuffer = '';
@@ -909,48 +1369,41 @@ export function parseMultipleKeypresses(prevState, input = '') {
909
1369
  }
910
1370
  }
911
1371
  else {
912
- const response = parseTerminalResponse(token.value);
913
- if (response) {
914
- // Terminal reply (DECRPM, DA, …) — same dead-report proof.
1372
+ // Fixed decision order (D6) for one complete token — see
1373
+ // claimProtocolSequence: OSC 8 drop → terminal reply → mouse.
1374
+ // SGR mouse comes first (1006); X10 (legacy 1000/1002 without SGR)
1375
+ // is the compatibility fallback. Wheel falls through to
1376
+ // parseKeypress, which turns it into a wheel key WITH the pointer
1377
+ // coordinates for position-based routing.
1378
+ const claimed = claimProtocolSequence(token.value);
1379
+ if (claimed !== null) {
1380
+ // Any claim — drop, terminal reply or complete mouse report — is
1381
+ // a protocol boundary: a held prefix belongs to an older, dead
1382
+ // report. Discard it BEFORE it can merge the next fragment into
1383
+ // a phantom event.
915
1384
  mouseTailHold = undefined;
916
1385
  mouseTailHoldAt = undefined;
917
- keys.push({ kind: 'response', sequence: token.value, response });
1386
+ keys.push(claimed);
1387
+ }
1388
+ else if (SGR_MOUSE_PREFIX_RE.test(token.value.replace(/^\x1b/, ''))) {
1389
+ // Flush-truncated SGR mouse report: the tokenizer's flush
1390
+ // emitted the buffered prefix (ESC still attached) as a
1391
+ // sequence token. It is protocol bytes mid-report, not a key —
1392
+ // strip the ESC, hold for the continuation (the text-token
1393
+ // branch above completes it), and never let it fall through
1394
+ // to parseKeypress, where it would leak into the prompt.
1395
+ // A fresh prefix REPLACES any stale hold instead of appending:
1396
+ // the new report's arrival proves the old one's tail never
1397
+ // came (`[<0;18` + `[<64;…` concatenated parses as garbage).
1398
+ mouseTailHold = token.value.replace(/^\x1b/, '');
1399
+ mouseTailHoldAt = Date.now();
918
1400
  }
919
1401
  else {
920
- // SGR first (1006); X10 (legacy 1000/1002 without SGR) as the
921
- // compatibility fallback for clicks/drags. Wheel falls through
922
- // to parseKeypress, which turns it into a wheel key WITH the
923
- // pointer coordinates for position-based routing.
924
- const mouse = parseMouseEvent(token.value) ??
925
- parseX10MouseEvent(token.value);
926
- if (mouse) {
927
- // A complete report arrived — any held prefix belongs to an
928
- // older, dead report. Discard it BEFORE it can merge the next
929
- // fragment into a phantom event.
930
- mouseTailHold = undefined;
931
- mouseTailHoldAt = undefined;
932
- keys.push(mouse);
933
- }
934
- else if (SGR_MOUSE_PREFIX_RE.test(token.value.replace(/^\x1b/, ''))) {
935
- // Flush-truncated SGR mouse report: the tokenizer's flush
936
- // emitted the buffered prefix (ESC still attached) as a
937
- // sequence token. It is protocol bytes mid-report, not a key —
938
- // strip the ESC, hold for the continuation (the text-token
939
- // branch above completes it), and never let it fall through
940
- // to parseKeypress, where it would leak into the prompt.
941
- // A fresh prefix REPLACES any stale hold instead of appending:
942
- // the new report's arrival proves the old one's tail never
943
- // came (`[<0;18` + `[<64;…` concatenated parses as garbage).
944
- mouseTailHold = token.value.replace(/^\x1b/, '');
945
- mouseTailHoldAt = Date.now();
946
- }
947
- else {
948
- // Ordinary key sequence (arrows, function keys, …) — still an
949
- // ESC protocol start, so a held prefix's report is dead.
950
- mouseTailHold = undefined;
951
- mouseTailHoldAt = undefined;
952
- keys.push(parseKeypress(token.value));
953
- }
1402
+ // Ordinary key sequence (arrows, function keys, …) — still an
1403
+ // ESC protocol start, so a held prefix's report is dead.
1404
+ mouseTailHold = undefined;
1405
+ mouseTailHoldAt = undefined;
1406
+ keys.push(parseKeypress(token.value));
954
1407
  }
955
1408
  }
956
1409
  }
@@ -964,6 +1417,7 @@ export function parseMultipleKeypresses(prevState, input = '') {
964
1417
  // a held prefix's report is dead. Discard before recovering.
965
1418
  mouseTailHold = undefined;
966
1419
  mouseTailHoldAt = undefined;
1420
+ terminalResponseReattachTail = undefined;
967
1421
  // A delayed win32-input-mode continuation can arrive after App's
968
1422
  // escape timer has already flushed its ESC prefix. Recover complete
969
1423
  // record tails so their protocol bytes do not leak into the prompt.
@@ -997,6 +1451,7 @@ export function parseMultipleKeypresses(prevState, input = '') {
997
1451
  // protocol bytes into the prompt as an ordinary key.
998
1452
  mouseTailHold = undefined;
999
1453
  mouseTailHoldAt = undefined;
1454
+ terminalResponseReattachTail = undefined;
1000
1455
  const resynthesized = '\x1b' + token.value;
1001
1456
  const mouse = parseMouseEvent(resynthesized);
1002
1457
  keys.push(mouse ?? parseKeypress(resynthesized));
@@ -1012,6 +1467,7 @@ export function parseMultipleKeypresses(prevState, input = '') {
1012
1467
  // protocol prefix, a win32 tail, or ordinary typing. The prefix
1013
1468
  // regex (no $ anchor) matches the report at the head; the suffix
1014
1469
  // is whatever follows.
1470
+ terminalResponseReattachTail = undefined;
1015
1471
  const combined = mouseTailHold + token.value;
1016
1472
  const m = combined.match(SGR_MOUSE_TAIL_PREFIX_RE);
1017
1473
  const reportEnd = m[0].length;
@@ -1041,6 +1497,7 @@ export function parseMultipleKeypresses(prevState, input = '') {
1041
1497
  // discards the hold once its grace expires. The regex demands `<` +
1042
1498
  // digits, which no realistic typed text produces as a single text
1043
1499
  // token.
1500
+ terminalResponseReattachTail = undefined;
1044
1501
  if (mouseTailHold === undefined)
1045
1502
  mouseTailHoldAt = Date.now();
1046
1503
  mouseTailHold = (mouseTailHold ?? '') + token.value;
@@ -1048,11 +1505,54 @@ export function parseMultipleKeypresses(prevState, input = '') {
1048
1505
  else {
1049
1506
  // Ordinary typing while a hold is pending: text that can never
1050
1507
  // continue an SGR report proves the held report died. Discard the
1051
- // stale hold so it cannot merge the NEXT fragment into a phantom
1052
- // event, then pass the text through untouched.
1508
+ // stale hold so it cannot merge the NEXT fragment into a phantom event.
1053
1509
  mouseTailHold = undefined;
1054
1510
  mouseTailHoldAt = undefined;
1055
- keys.push(parseKeypress(token.value));
1511
+ // #796 re-attach + post-flush reassembly: put the flushed introducer
1512
+ // back, then let the one reply-shape authority decide. A fragment
1513
+ // held from an earlier call is joined with this token first, so a
1514
+ // tail that is itself split again (`[?61;4;6` then `;14;21;22c`) is
1515
+ // reassembled instead of falling into the prompt in pieces. A claim
1516
+ // needs BOTH a pending query of the matching expected type and a
1517
+ // shape that can still complete into it; anything else keeps the
1518
+ // token literal (AC-3) and drops a stale fragment rather than
1519
+ // leaking protocol bytes.
1520
+ const candidate = (terminalResponseReattachTail ?? '') + token.value;
1521
+ const claimed = mayRecoverTerminalResponseTail
1522
+ ? claimExpectedResponseHead(candidate, expectedResponseTypes)
1523
+ : undefined;
1524
+ if (claimed) {
1525
+ terminalResponseReattachTail = undefined;
1526
+ keys.push({
1527
+ kind: 'response',
1528
+ sequence: '\x1b' + candidate.slice(0, claimed.consumed),
1529
+ response: claimed.response,
1530
+ });
1531
+ // Trailing bytes beyond the completed reply are ordinary input
1532
+ // (the terminal batched the tail with the next keystrokes).
1533
+ const suffix = candidate.slice(claimed.consumed);
1534
+ if (suffix)
1535
+ tokenQueue.splice(qi + 1, 0, { type: 'text', value: suffix });
1536
+ }
1537
+ else if (mayRecoverTerminalResponseTail &&
1538
+ candidate.length <= TERMINAL_RESPONSE_MAX_LENGTH &&
1539
+ expectsResponseType('\x1b' + candidate, expectedResponseTypes)) {
1540
+ // Still an open expected reply prefix — hold it for the next call.
1541
+ terminalResponseReattachTail = candidate;
1542
+ }
1543
+ else {
1544
+ // The held fragment (if any) cannot be completed; drop it and give
1545
+ // the fresh bytes their own chance to start a reply prefix.
1546
+ terminalResponseReattachTail = undefined;
1547
+ if (mayRecoverTerminalResponseTail &&
1548
+ token.value.length <= TERMINAL_RESPONSE_MAX_LENGTH &&
1549
+ expectsResponseType('\x1b' + token.value, expectedResponseTypes)) {
1550
+ terminalResponseReattachTail = token.value;
1551
+ }
1552
+ else {
1553
+ keys.push(parseKeypress(token.value));
1554
+ }
1555
+ }
1056
1556
  }
1057
1557
  }
1058
1558
  }
@@ -1120,6 +1620,24 @@ export function parseMultipleKeypresses(prevState, input = '') {
1120
1620
  // as text are exactly the leak this hold exists to prevent. Partial
1121
1621
  // recovery of the coords is not worth one more branch — a mouse event
1122
1622
  // with guessed terminators would dispatch phantom clicks.
1623
+ // The reply-prefix hold lives only while the tokenizer still retains a
1624
+ // reply-shaped prefix: completion, release or reset all end it, so the
1625
+ // NEXT hold captures its own first-capture deadline.
1626
+ if (terminalResponseHoldAt !== undefined && !isResponsePrefix(tokenizer.buffer())) {
1627
+ terminalResponseHoldAt = undefined;
1628
+ }
1629
+ // A REAL lone-Escape flush — an Escape emitted as its own key, not the ESC
1630
+ // introducer of a buffered sequence. Only this opens the #796 re-attach
1631
+ // window; the window alone is not evidence (D1), and a direct caller that
1632
+ // never injects the provenance keeps the pre-change behavior.
1633
+ const escapeFlushedNow = isFlush &&
1634
+ keys.some(key => key.kind === 'key' &&
1635
+ key.name === 'escape' &&
1636
+ key.sequence === '\x1b');
1637
+ // A fresh lone-Escape flush opens a NEW window: a fragment held against
1638
+ // the old one cannot be part of a reply whose introducer just arrived.
1639
+ if (escapeFlushedNow)
1640
+ terminalResponseReattachTail = undefined;
1123
1641
  // Build new state
1124
1642
  const newState = {
1125
1643
  mode: inPaste ? 'IN_PASTE' : 'NORMAL',
@@ -1140,6 +1658,9 @@ export function parseMultipleKeypresses(prevState, input = '') {
1140
1658
  // The host gate rides along: App replaces its state with this object on
1141
1659
  // every read, and the hold must stay closed for the whole session.
1142
1660
  win32Capable: prevState.win32Capable,
1661
+ // Same host-injected contract for the query evidence: read-only here,
1662
+ // re-injected by App on every input call, so it is never stale.
1663
+ terminalExpectedResponseTypes: prevState.terminalExpectedResponseTypes,
1143
1664
  win32InputMode,
1144
1665
  win32InputStartedAt,
1145
1666
  win32EscFlushedAt,
@@ -1149,6 +1670,13 @@ export function parseMultipleKeypresses(prevState, input = '') {
1149
1670
  win32Protocol,
1150
1671
  mouseTailHold,
1151
1672
  mouseTailHoldAt,
1673
+ terminalResponseHoldAt,
1674
+ terminalResponseReattachTail,
1675
+ // A fresh lone-Escape flush re-arms the window; otherwise the carried
1676
+ // stamp keeps it open (see carryTailAfterEscFlush above).
1677
+ terminalResponseTailAfterEscFlushAt: escapeFlushedNow
1678
+ ? Date.now()
1679
+ : carryTailAfterEscFlush,
1152
1680
  _tokenizer: tokenizer,
1153
1681
  };
1154
1682
  return [keys, newState];