dsh-locator 0.8.2 → 0.10.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 (58) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README-es.md +30 -5
  3. package/README-hi.md +31 -5
  4. package/README-pt.md +31 -5
  5. package/README.en.md +31 -5
  6. package/README.md +31 -5
  7. package/bin/darwin-arm64/indexd +0 -0
  8. package/bin/darwin-x64/indexd +0 -0
  9. package/bin/linux-arm64/indexd +0 -0
  10. package/bin/linux-x64/indexd +0 -0
  11. package/bin/win32-arm64/indexd.exe +0 -0
  12. package/bin/win32-x64/indexd.exe +0 -0
  13. package/client.js +1198 -117
  14. package/lib/combined.d.ts +20 -3
  15. package/lib/combined.d.ts.map +1 -1
  16. package/lib/combined.js +85 -15
  17. package/lib/combined.js.map +1 -1
  18. package/lib/content-search-worker.d.ts +8 -3
  19. package/lib/content-search-worker.d.ts.map +1 -1
  20. package/lib/content-search-worker.js +54 -27
  21. package/lib/content-search-worker.js.map +1 -1
  22. package/lib/content-search.d.ts +127 -7
  23. package/lib/content-search.d.ts.map +1 -1
  24. package/lib/content-search.js +571 -36
  25. package/lib/content-search.js.map +1 -1
  26. package/lib/content.d.ts +83 -5
  27. package/lib/content.d.ts.map +1 -1
  28. package/lib/content.js +351 -87
  29. package/lib/content.js.map +1 -1
  30. package/lib/export.d.ts +61 -0
  31. package/lib/export.d.ts.map +1 -1
  32. package/lib/export.js +217 -52
  33. package/lib/export.js.map +1 -1
  34. package/lib/find.d.ts.map +1 -1
  35. package/lib/find.js +73 -16
  36. package/lib/find.js.map +1 -1
  37. package/lib/http.d.ts +38 -2
  38. package/lib/http.d.ts.map +1 -1
  39. package/lib/http.js +279 -82
  40. package/lib/http.js.map +1 -1
  41. package/lib/reveal.d.ts +69 -3
  42. package/lib/reveal.d.ts.map +1 -1
  43. package/lib/reveal.js +140 -11
  44. package/lib/reveal.js.map +1 -1
  45. package/lib/tools.d.ts +30 -0
  46. package/lib/tools.d.ts.map +1 -1
  47. package/lib/tools.js +65 -3
  48. package/lib/tools.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/combined.ts +107 -17
  51. package/src/content-search-worker.ts +52 -24
  52. package/src/content-search.ts +710 -36
  53. package/src/content.ts +349 -73
  54. package/src/export.ts +240 -50
  55. package/src/find.ts +83 -16
  56. package/src/http.ts +271 -83
  57. package/src/reveal.ts +148 -10
  58. package/src/tools.ts +83 -6
package/client.js CHANGED
@@ -123,8 +123,39 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
123
123
  background:var(--dsw-alias-bg-layer-2,rgba(30,32,38,.92));color:var(--dsw-alias-label-primary,#fff);
124
124
  border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));box-shadow:0 8px 24px rgba(0,0,0,.25);
125
125
  animation:dsh-locator-in 120ms ease-out}
126
- ::highlight(dsh-locator-find){background-color:rgba(245,158,11,.28);color:inherit}
127
- ::highlight(dsh-locator-find-active){background-color:rgba(245,158,11,.45);color:inherit}
126
+ ::highlight(dsh-locator-find){background-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 28%,transparent);color:inherit}
127
+ ::highlight(dsh-locator-find-active){background-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 50%,transparent);color:inherit;
128
+ text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:2px}
129
+ ::highlight(dsh-locator-find-scope){background-color:color-mix(in srgb,var(--dsw-alias-bg-document-selection,rgba(90,120,255,.25)) 35%,transparent)}
130
+ .dsh-locator-findbar{position:fixed;z-index:70;display:flex;flex-direction:column;width:min(560px,calc(100vw - 24px));
131
+ border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-base,rgba(120,125,135,.28)));
132
+ border-radius:var(--dsw-radius-md,12px);background:var(--dsw-menu-surface-fill,var(--dsw-alias-bg-base,#fff));
133
+ color:var(--dsw-alias-label-primary,inherit);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.24));
134
+ font:13px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;animation:dsh-locator-in 120ms ease-out}
135
+ @media (prefers-reduced-motion:reduce){.dsh-locator-findbar{animation:none}}
136
+ .dsh-locator-findbar-main{display:flex;align-items:center;gap:6px;padding:6px 8px}
137
+ .dsh-locator-findbar-glyph{font-size:14px;opacity:.6;padding:0 2px}
138
+ .dsh-locator-findbar-input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;font-size:13px;padding:3px 0}
139
+ .dsh-locator-findbar-count{font-variant-numeric:tabular-nums;font-size:12px;opacity:.75;white-space:nowrap;min-width:52px;text-align:right}
140
+ .dsh-locator-findbar-btn{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 5px;
141
+ border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:13px}
142
+ .dsh-locator-findbar-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(120,125,135,.14))}
143
+ .dsh-locator-findbar-btn:disabled{opacity:.4;cursor:default}
144
+ .dsh-locator-findbar-btn[aria-pressed=true]{background:var(--dsw-alias-interactive-bg-active,rgba(64,116,255,.18));
145
+ color:var(--dsw-alias-state-business-primary,#4f7cff)}
146
+ .dsh-locator-findbar-opts{display:flex;align-items:center;gap:8px;padding:5px 9px 7px;flex-wrap:wrap;
147
+ border-top:1px solid var(--dsw-alias-border-l1,var(--dsw-alias-border-base,rgba(120,125,135,.16)));font-size:11.5px}
148
+ .dsh-locator-findbar-opts select{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));border-radius:5px;
149
+ background:transparent;color:inherit;font:inherit;font-size:11.5px;padding:1px 3px}
150
+ .dsh-locator-findbar-label{display:inline-flex;align-items:center;gap:4px;opacity:.8;white-space:nowrap}
151
+ .dsh-locator-findbar-status{display:flex;align-items:center;gap:8px;padding:4px 10px 6px;font-size:11.5px;opacity:.75;flex-wrap:wrap}
152
+ .dsh-locator-findbar-status .grow{flex:1}
153
+ .dsh-locator-findbar-error{padding:4px 10px 6px;font-size:11.5px;color:var(--dsw-alias-state-error-primary,#d33);
154
+ background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#d33) 9%,transparent)}
155
+ .dsh-locator-findbar-chip{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:999px;
156
+ background:var(--dsw-alias-bg-layer-2,rgba(120,125,135,.14));font-size:11px;white-space:nowrap}
157
+ .dsh-locator-findbar-chip button{border:0;background:transparent;color:inherit;cursor:pointer;padding:0 1px;font:inherit;font-size:12px}
158
+ .dsh-locator-findbar kbd{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));border-radius:4px;padding:0 3px;font-size:10px;opacity:.6}
128
159
  `
129
160
  function ensureStyles() {
130
161
  if (typeof document === 'undefined') return
@@ -161,7 +192,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
161
192
  asc: 'Ascending', descOrder: 'Descending',
162
193
  groupContent: 'Content', groupFiles: 'Files', groupDirs: 'Folders',
163
194
  relevance: 'by relevance', contentBuilding: 'indexing… {files} files / {messages} messages',
164
- contentShort: 'Type at least 2 characters to search bodies and sessions',
195
+ contentShort: 'Type at least 3 characters to search bodies and sessions (Ctrl+F handles shorter queries)',
165
196
  contentDisabled: 'Content index is disabled (contentIndex: false)',
166
197
  contentCapped: 'first {n}', showing: 'first {n}', deduped: '{n} name hits merged into content', loading: 'Searching…',
167
198
  emptyQuery: 'Type to search file names, paths, bodies and sessions',
@@ -177,6 +208,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
177
208
  launcher: 'Local file search (Ctrl+Shift+F)',
178
209
  justNow: 'just now', minutesAgo: '{n} min ago', hoursAgo: '{n} h ago',
179
210
  phraseNotFound: 'No match in the conversation (it may not be loaded yet)',
211
+ findOpen: 'Find in conversation', findTitle: 'Find in conversation', findAria: 'Find in conversation',
212
+ findPlaceholder: 'Find in conversation…', findPlaceholderSelection: 'Find in selection…',
213
+ findPrev: 'Previous match (Shift+F3)', findNext: 'Next match (F3)', findClose: 'Close (Esc)',
214
+ findCase: 'Match case', findWhole: 'Whole word', findWrap: 'Wrap around', findReverse: 'Search backwards',
215
+ findMode: 'Mode', modeNormal: 'Normal', modeExtended: 'Extended', modeRegex: 'Regex',
216
+ findScope: 'In selection', findScopeChip: 'selection {n} chars', findSessionChip: 'this conversation',
217
+ findNoMatch: 'No matches', findEmpty: 'Type to find in this conversation',
218
+ findInvalidRegex: 'Invalid regular expression: {message}', findInvalidEscape: 'Invalid extended escape: {token}',
219
+ findTimeout: 'Regex timed out; simplify the expression', findRegexUnavailable: 'Regex search is unavailable here (worker blocked)', findWrappedTop: 'Wrapped to top',
220
+ findWrappedBottom: 'Wrapped to bottom', findNoWrapEnd: 'Reached the last match (wrap is off)',
221
+ findNoWrapStart: 'Reached the first match (wrap is off)', findOlder: 'Load earlier messages and retry',
222
+ findLoadingOlder: 'Loading earlier messages…', findLoadedOnly: 'loaded window only · earlier history not loaded',
223
+ findCapped: 'capped', findCopied: 'Match copied', findSelectionLost: 'Selection expired; searching the whole conversation',
224
+ findNoSession: 'Open a conversation first', findOptions: 'Options',
225
+ findExtendedHelp: 'Extended: \\n \\r \\t \\0 \\xHH \\\\',
226
+ panelTotal: 'selected {index}/{total}', panelRangeAll: 'All indexed', panelRangeSession: 'This conversation',
227
+ panelSessionHint: 'This scope searches indexed session messages only; press Ctrl+F for live in-conversation find',
228
+ panelRegexPaused: 'Content scope paused in regex mode', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
229
+ contentWarning: 'Index warning: {list}', contentBudget: 'Content index budget reached; some files are not indexed', contentTooShort: 'Content needs at least 3 characters (use Ctrl+F for shorter searches)', contentTimeout: 'Content search timed out; narrow the query or scope', contentBadChars: 'Content search does not support NUL/control characters',
230
+ panelCaseShort: 'Aa', panelWholeShort: 'Word', panelModeLabel: 'Mode',
180
231
  },
181
232
  zh: {
182
233
  scopeContent: '内容', scopeFiles: '文件', scopeDirs: '目录',
@@ -186,7 +237,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
186
237
  asc: '升序', descOrder: '降序',
187
238
  groupContent: '内容', groupFiles: '文件', groupDirs: '目录',
188
239
  relevance: '按相关度', contentBuilding: '索引构建中… {files} 文件 / {messages} 消息',
189
- contentShort: '输入至少 2 个字符后搜索正文与会话',
240
+ contentShort: '输入至少 3 个字符后搜索正文与会话(更短的词请用 Ctrl+F)',
190
241
  contentDisabled: '内容索引已关闭(contentIndex: false)',
191
242
  contentCapped: '前 {n} 条', showing: '前 {n} 条', deduped: '{n} 条文件命中已并入内容', loading: '搜索中…',
192
243
  emptyQuery: '输入关键词:文件名、路径、正文或会话内容',
@@ -202,6 +253,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
202
253
  launcher: '本地文件搜索 (Ctrl+Shift+F)',
203
254
  justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
204
255
  phraseNotFound: '会话里没有找到匹配内容(可能还没加载)',
256
+ findOpen: '在会话中查找', findTitle: '在会话中查找', findAria: '在会话中查找',
257
+ findPlaceholder: '在会话中查找…', findPlaceholderSelection: '在选区内查找…',
258
+ findPrev: '上一个匹配 (Shift+F3)', findNext: '下一个匹配 (F3)', findClose: '关闭 (Esc)',
259
+ findCase: '区分大小写', findWhole: '全词匹配', findWrap: '循环查找', findReverse: '反向查找',
260
+ findMode: '模式', modeNormal: '普通', modeExtended: '扩展', modeRegex: '正则',
261
+ findScope: '仅在选区内', findScopeChip: '选区 {n} 字', findSessionChip: '本会话',
262
+ findNoMatch: '没有匹配', findEmpty: '输入关键词后在此会话中查找',
263
+ findInvalidRegex: '正则无效:{message}', findInvalidEscape: '扩展转义无效:{token}',
264
+ findTimeout: '正则执行超时,请简化表达式', findRegexUnavailable: '此处无法运行正则(worker 被阻止)', findWrappedTop: '已回到顶部',
265
+ findWrappedBottom: '已到底部,回到开头', findNoWrapEnd: '已到最后一个匹配(循环查找已关闭)',
266
+ findNoWrapStart: '已到第一个匹配(循环查找已关闭)', findOlder: '加载更早消息后重试',
267
+ findLoadingOlder: '正在加载更早消息…', findLoadedOnly: '仅已加载窗口 · 更早历史未加载',
268
+ findCapped: '已截断', findCopied: '已复制匹配', findSelectionLost: '选区已失效,已切回整个会话',
269
+ findNoSession: '请先打开一个会话', findOptions: '选项',
270
+ findExtendedHelp: '扩展:\\n \\r \\t \\0 \\xHH \\\\',
271
+ panelTotal: '已选 {index}/{total}', panelRangeAll: '全部索引', panelRangeSession: '当前会话',
272
+ panelSessionHint: '此范围只查已索引的会话消息;实时查找请按 Ctrl+F',
273
+ panelRegexPaused: '正则模式下内容范围暂停', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
274
+ contentWarning: '索引警告:{list}', contentBudget: '内容索引已达预算上限,部分文件未被收录', contentTooShort: '内容搜索至少需要 3 个字符(更短的词请用 Ctrl+F 在会话内查找)', contentTimeout: '内容搜索超时,请缩小关键词或范围', contentBadChars: '内容搜索不支持 NUL/控制字符',
275
+ panelCaseShort: 'Aa', panelWholeShort: '全词', panelModeLabel: '模式',
205
276
  },
206
277
  es: {
207
278
  scopeContent: 'Contenido', scopeFiles: 'Archivos', scopeDirs: 'Carpetas',
@@ -211,7 +282,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
211
282
  asc: 'Ascendente', descOrder: 'Descendente',
212
283
  groupContent: 'Contenido', groupFiles: 'Archivos', groupDirs: 'Carpetas',
213
284
  relevance: 'por relevancia', contentBuilding: 'indexando… {files} archivos / {messages} mensajes',
214
- contentShort: 'Escribe al menos 2 caracteres para buscar contenido y sesiones',
285
+ contentShort: 'Escribe al menos 3 caracteres para buscar contenido y sesiones (Ctrl+F para consultas cortas)',
215
286
  contentDisabled: 'Índice de contenido desactivado (contentIndex: false)',
216
287
  contentCapped: 'primeros {n}', showing: 'primeros {n}', deduped: '{n} aciertos de nombre fusionados', loading: 'Buscando…',
217
288
  emptyQuery: 'Escribe para buscar nombres, rutas, contenido y sesiones',
@@ -227,6 +298,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
227
298
  launcher: 'Búsqueda local (Ctrl+Shift+F)',
228
299
  justNow: 'ahora', minutesAgo: 'hace {n} min', hoursAgo: 'hace {n} h',
229
300
  phraseNotFound: 'No se encontró en la conversación (quizá no está cargada)',
301
+ findOpen: 'Buscar en la conversación', findTitle: 'Buscar en la conversación', findAria: 'Buscar en la conversación',
302
+ findPlaceholder: 'Buscar en la conversación…', findPlaceholderSelection: 'Buscar en la selección…',
303
+ findPrev: 'Coincidencia anterior (Shift+F3)', findNext: 'Siguiente coincidencia (F3)', findClose: 'Cerrar (Esc)',
304
+ findCase: 'Distinguir mayúsculas', findWhole: 'Palabra completa', findWrap: 'Continuar al inicio', findReverse: 'Buscar hacia atrás',
305
+ findMode: 'Modo', modeNormal: 'Normal', modeExtended: 'Extendido', modeRegex: 'Regex',
306
+ findScope: 'En la selección', findScopeChip: 'selección {n} caracteres', findSessionChip: 'esta conversación',
307
+ findNoMatch: 'Sin coincidencias', findEmpty: 'Escribe para buscar en esta conversación',
308
+ findInvalidRegex: 'Expresión regular no válida: {message}', findInvalidEscape: 'Escape extendido no válido: {token}',
309
+ findTimeout: 'La regex agotó el tiempo; simplifícala', findRegexUnavailable: 'La búsqueda regex no está disponible aquí (worker bloqueado)', findWrappedTop: 'Vuelto al inicio',
310
+ findWrappedBottom: 'Vuelto al final', findNoWrapEnd: 'Última coincidencia (continuación desactivada)',
311
+ findNoWrapStart: 'Primera coincidencia (continuación desactivada)', findOlder: 'Cargar mensajes anteriores y reintentar',
312
+ findLoadingOlder: 'Cargando mensajes anteriores…', findLoadedOnly: 'solo ventana cargada · historial anterior sin cargar',
313
+ findCapped: 'limitado', findCopied: 'Coincidencia copiada', findSelectionLost: 'La selección caducó; buscando en toda la conversación',
314
+ findNoSession: 'Abre primero una conversación', findOptions: 'Opciones',
315
+ findExtendedHelp: 'Extendido: \\n \\r \\t \\0 \\xHH \\\\',
316
+ panelTotal: 'seleccionado {index}/{total}', panelRangeAll: 'Todo el índice', panelRangeSession: 'Esta conversación',
317
+ panelSessionHint: 'Este ámbito solo busca mensajes indexados; pulsa Ctrl+F para buscar en vivo',
318
+ panelRegexPaused: 'Ámbito de contenido en pausa en modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
319
+ contentWarning: 'Aviso del índice: {list}', contentBudget: 'Se alcanzó el presupuesto del índice; algunos archivos no están indexados', contentTooShort: 'El contenido requiere al menos 3 caracteres (usa Ctrl+F para búsquedas más cortas)', contentTimeout: 'La búsqueda de contenido agotó el tiempo; reduce la consulta o el ámbito', contentBadChars: 'La búsqueda de contenido no admite NUL/caracteres de control',
320
+ panelCaseShort: 'Aa', panelWholeShort: 'Palabra', panelModeLabel: 'Modo',
230
321
  },
231
322
  pt: {
232
323
  scopeContent: 'Conteúdo', scopeFiles: 'Arquivos', scopeDirs: 'Pastas',
@@ -236,7 +327,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
236
327
  asc: 'Ascendente', descOrder: 'Descendente',
237
328
  groupContent: 'Conteúdo', groupFiles: 'Arquivos', groupDirs: 'Pastas',
238
329
  relevance: 'por relevância', contentBuilding: 'indexando… {files} arquivos / {messages} mensagens',
239
- contentShort: 'Digite pelo menos 2 caracteres para buscar conteúdo e sessões',
330
+ contentShort: 'Digite pelo menos 3 caracteres para buscar conteúdo e sessões (Ctrl+F para consultas curtas)',
240
331
  contentDisabled: 'Índice de conteúdo desativado (contentIndex: false)',
241
332
  contentCapped: 'primeiros {n}', showing: 'primeiros {n}', deduped: '{n} acertos de nome mesclados', loading: 'Buscando…',
242
333
  emptyQuery: 'Digite para buscar nomes, caminhos, conteúdo e sessões',
@@ -252,6 +343,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
252
343
  launcher: 'Busca local (Ctrl+Shift+F)',
253
344
  justNow: 'agora', minutesAgo: 'há {n} min', hoursAgo: 'há {n} h',
254
345
  phraseNotFound: 'Não encontrado na conversa (talvez ainda não carregada)',
346
+ findOpen: 'Buscar na conversa', findTitle: 'Buscar na conversa', findAria: 'Buscar na conversa',
347
+ findPlaceholder: 'Buscar na conversa…', findPlaceholderSelection: 'Buscar na seleção…',
348
+ findPrev: 'Ocorrência anterior (Shift+F3)', findNext: 'Próxima ocorrência (F3)', findClose: 'Fechar (Esc)',
349
+ findCase: 'Diferenciar maiúsculas', findWhole: 'Palavra inteira', findWrap: 'Continuar do início', findReverse: 'Buscar para trás',
350
+ findMode: 'Modo', modeNormal: 'Normal', modeExtended: 'Estendido', modeRegex: 'Regex',
351
+ findScope: 'Na seleção', findScopeChip: 'seleção {n} caracteres', findSessionChip: 'esta conversa',
352
+ findNoMatch: 'Sem resultados', findEmpty: 'Digite para buscar nesta conversa',
353
+ findInvalidRegex: 'Expressão regular inválida: {message}', findInvalidEscape: 'Escape estendido inválido: {token}',
354
+ findTimeout: 'A regex excedeu o tempo; simplifique', findRegexUnavailable: 'A busca regex não está disponível aqui (worker bloqueado)', findWrappedTop: 'Voltou ao início',
355
+ findWrappedBottom: 'Voltou ao fim', findNoWrapEnd: 'Última ocorrência (continuação desativada)',
356
+ findNoWrapStart: 'Primeira ocorrência (continuação desativada)', findOlder: 'Carregar mensagens anteriores e tentar novamente',
357
+ findLoadingOlder: 'Carregando mensagens anteriores…', findLoadedOnly: 'apenas janela carregada · histórico anterior não carregado',
358
+ findCapped: 'limitado', findCopied: 'Ocorrência copiada', findSelectionLost: 'A seleção expirou; buscando em toda a conversa',
359
+ findNoSession: 'Abra uma conversa primeiro', findOptions: 'Opções',
360
+ findExtendedHelp: 'Estendido: \\n \\r \\t \\0 \\xHH \\\\',
361
+ panelTotal: 'selecionado {index}/{total}', panelRangeAll: 'Todo o índice', panelRangeSession: 'Esta conversa',
362
+ panelSessionHint: 'Este escopo busca apenas mensagens indexadas; use Ctrl+F para buscar ao vivo',
363
+ panelRegexPaused: 'Escopo de conteúdo pausado no modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
364
+ contentWarning: 'Aviso do índice: {list}', contentBudget: 'O orçamento do índice foi atingido; alguns arquivos não estão indexados', contentTooShort: 'O conteúdo exige pelo menos 3 caracteres (use Ctrl+F para buscas menores)', contentTimeout: 'A busca de conteúdo excedeu o tempo; reduza a consulta ou o escopo', contentBadChars: 'A busca de conteúdo não suporta NUL/caracteres de controle',
365
+ panelCaseShort: 'Aa', panelWholeShort: 'Palavra', panelModeLabel: 'Modo',
255
366
  },
256
367
  hi: {
257
368
  scopeContent: 'सामग्री', scopeFiles: 'फ़ाइलें', scopeDirs: 'फ़ोल्डर',
@@ -261,7 +372,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
261
372
  asc: 'आरोही', descOrder: 'अवरोही',
262
373
  groupContent: 'सामग्री', groupFiles: 'फ़ाइलें', groupDirs: 'फ़ोल्डर',
263
374
  relevance: 'प्रासंगिकता से', contentBuilding: 'इंडेक्स बन रहा है… {files} फ़ाइलें / {messages} संदेश',
264
- contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 2 अक्षर लिखें',
375
+ contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 3 अक्षर लिखें (छोटी खोज के लिए Ctrl+F)',
265
376
  contentDisabled: 'सामग्री इंडेक्स बंद है (contentIndex: false)',
266
377
  contentCapped: 'पहले {n}', showing: 'पहले {n}', deduped: '{n} नाम-परिणाम सामग्री में मिलाए', loading: 'खोज रहे हैं…',
267
378
  emptyQuery: 'नाम, पथ, सामग्री और सत्र खोजने के लिए लिखें',
@@ -277,6 +388,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
277
388
  launcher: 'स्थानीय फ़ाइल खोज (Ctrl+Shift+F)',
278
389
  justNow: 'अभी', minutesAgo: '{n} मिनट पहले', hoursAgo: '{n} घंटे पहले',
279
390
  phraseNotFound: 'बातचीत में नहीं मिला (शायद अभी लोड नहीं हुआ)',
391
+ findOpen: 'बातचीत में खोजें', findTitle: 'बातचीत में खोजें', findAria: 'बातचीत में खोजें',
392
+ findPlaceholder: 'बातचीत में खोजें…', findPlaceholderSelection: 'चयन में खोजें…',
393
+ findPrev: 'पिछला मेल (Shift+F3)', findNext: 'अगला मेल (F3)', findClose: 'बंद करें (Esc)',
394
+ findCase: 'केस मिलाएँ', findWhole: 'पूरा शब्द', findWrap: 'चक्रीय खोज', findReverse: 'उल्टा खोजें',
395
+ findMode: 'मोड', modeNormal: 'सामान्य', modeExtended: 'विस्तृत', modeRegex: 'रेगेक्स',
396
+ findScope: 'चयन में', findScopeChip: 'चयन {n} अक्षर', findSessionChip: 'यह बातचीत',
397
+ findNoMatch: 'कोई मेल नहीं', findEmpty: 'इस बातचीत में खोजने के लिए लिखें',
398
+ findInvalidRegex: 'अमान्य रेगुलर एक्सप्रेशन: {message}', findInvalidEscape: 'अमान्य विस्तृत एस्केप: {token}',
399
+ findTimeout: 'रेगेक्स का समय समाप्त; सरल बनाएँ', findRegexUnavailable: 'यहाँ रेगेक्स उपलब्ध नहीं है (वर्कर अवरुद्ध)', findWrappedTop: 'शीर्ष पर लौटा',
400
+ findWrappedBottom: 'अंत पर लौटा', findNoWrapEnd: 'अंतिम मेल (चक्र बंद)',
401
+ findNoWrapStart: 'पहला मेल (चक्र बंद)', findOlder: 'पुराने संदेश लोड कर पुन: प्रयास',
402
+ findLoadingOlder: 'पुराने संदेश लोड हो रहे हैं…', findLoadedOnly: 'केवल लोडेड विंडो · पुराना इतिहास लोड नहीं',
403
+ findCapped: 'सीमित', findCopied: 'मेल कॉपी हुआ', findSelectionLost: 'चयन समाप्त; पूरी बातचीत में खोज',
404
+ findNoSession: 'पहले एक बातचीत खोलें', findOptions: 'विकल्प',
405
+ findExtendedHelp: 'विस्तृत: \\n \\r \\t \\0 \\xHH \\\\',
406
+ panelTotal: 'चयनित {index}/{total}', panelRangeAll: 'पूरा इंडेक्स', panelRangeSession: 'यह बातचीत',
407
+ panelSessionHint: 'यह क्षेत्र केवल इंडेक्स किए संदेश खोजता है; लाइव खोज के लिए Ctrl+F दबाएँ',
408
+ panelRegexPaused: 'रेगेक्स मोड में सामग्री क्षेत्र रुका है', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
409
+ contentWarning: 'इंडेक्स चेतावनी: {list}', contentBudget: 'सामग्री इंडेक्स बजट पूरा; कुछ फ़ाइलें इंडेक्स नहीं हैं', contentTooShort: 'सामग्री के लिए कम से कम 3 अक्षर चाहिए (छोटी खोज के लिए Ctrl+F)', contentTimeout: 'सामग्री खोज का समय समाप्त; क्वेरी या क्षेत्र घटाएँ', contentBadChars: 'सामग्री खोज NUL/नियंत्रण अक्षर नहीं लेती',
410
+ panelCaseShort: 'Aa', panelWholeShort: 'शब्द', panelModeLabel: 'मोड',
280
411
  },
281
412
  }
282
413
  const LANG = (() => {
@@ -382,7 +513,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
382
513
  const PREFS_KEY = 'dsh-locator.panel.v1'
383
514
  const SORTS = ['mtime', 'name', 'size', 'path']
384
515
  function readPrefs() {
385
- const fallback = { scopes: { content: true, files: true, dirs: true }, sort: 'mtime', desc: true }
516
+ const fallback = { scopes: { content: true, files: true, dirs: true }, sort: 'mtime', desc: true,
517
+ matchMode: 'normal', caseSensitive: false, wholeWord: false }
386
518
  try {
387
519
  const raw = window.localStorage.getItem(PREFS_KEY)
388
520
  if (raw === null) return fallback
@@ -397,6 +529,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
397
529
  },
398
530
  sort: SORTS.includes(value.sort) ? value.sort : 'mtime',
399
531
  desc: typeof value.desc === 'boolean' ? value.desc : true,
532
+ matchMode: ['normal', 'extended', 'regex'].includes(value.matchMode) ? value.matchMode : 'normal',
533
+ caseSensitive: typeof value.caseSensitive === 'boolean' ? value.caseSensitive : false,
534
+ wholeWord: typeof value.wholeWord === 'boolean' ? value.wholeWord : false,
400
535
  }
401
536
  } catch {
402
537
  return fallback
@@ -410,7 +545,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
410
545
  const HL_BASE = 'dsh-locator-find'
411
546
  const HL_ACTIVE = 'dsh-locator-find-active'
412
547
  const GESTURES = ['wheel', 'touchstart', 'pointerdown', 'keydown']
413
- const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style'
548
+ const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style,'
549
+ + '.sr-only,[class*="visuallyHidden"],[role="status"],[aria-live]'
414
550
  const BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
415
551
  const legacyTargets = new Set()
416
552
 
@@ -424,26 +560,39 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
424
560
  legacyTargets.clear()
425
561
  }
426
562
 
427
- function clearFindHighlight() {
563
+ const HL_SCOPE = 'dsh-locator-find-scope'
564
+ let highlightOwner = null
565
+
566
+ function clearFindHighlight(owner) {
567
+ if (owner && highlightOwner !== null && owner !== highlightOwner) return
568
+ highlightOwner = null
428
569
  clearLegacyTargets()
429
570
  if (!highlightsAvailable()) return
430
571
  CSS.highlights.delete(HL_BASE)
431
572
  CSS.highlights.delete(HL_ACTIVE)
573
+ CSS.highlights.delete(HL_SCOPE)
432
574
  }
433
575
 
434
- function setFindHighlight(ranges) {
576
+ function setFindHighlight(ranges, activeIndex, owner) {
577
+ clearFindHighlight()
578
+ if (ranges.length === 0) return
579
+ highlightOwner = owner || null
580
+ const active = ranges[Math.max(0, Math.min(ranges.length - 1, activeIndex || 0))]
435
581
  if (!highlightsAvailable()) {
436
- for (const range of ranges.slice(0, 1)) {
437
- const node = range.startContainer
438
- const element = node instanceof Element ? node : node.parentElement
439
- const row = element === null ? null : element.closest('[data-chat-node-key]')
440
- if (row !== null) { row.classList.add('dsh-locator-target'); legacyTargets.add(row) }
441
- }
582
+ const node = active.startContainer
583
+ const element = node instanceof Element ? node : node.parentElement
584
+ const row = element === null ? null : element.closest('[data-chat-node-key]')
585
+ if (row !== null) { row.classList.add('dsh-locator-target'); legacyTargets.add(row) }
442
586
  return
443
587
  }
444
- if (ranges.length === 0) return
445
588
  CSS.highlights.set(HL_BASE, new Highlight(...ranges))
446
- CSS.highlights.set(HL_ACTIVE, new Highlight(ranges[0]))
589
+ CSS.highlights.set(HL_ACTIVE, new Highlight(active))
590
+ }
591
+
592
+ function setFindScope(range) {
593
+ if (!highlightsAvailable()) return
594
+ CSS.highlights.delete(HL_SCOPE)
595
+ if (range !== null) CSS.highlights.set(HL_SCOPE, new Highlight(range))
447
596
  }
448
597
 
449
598
  function isVisible(element) {
@@ -468,21 +617,28 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
468
617
  return scope === null ? null : scope.querySelector('[data-chat-flow]')
469
618
  }
470
619
 
471
- function findMessageRow(flow, messageId, nodeKey) {
472
- if (flow === null) return null
620
+ function findMessageRows(flow, messageId, nodeKey) {
621
+ if (flow === null) return []
473
622
  const rows = flow.querySelectorAll('[data-chat-node-key]')
623
+ const exact = []
474
624
  if (nodeKey) {
475
625
  for (const row of rows) {
476
- if (row.getAttribute('data-chat-node-key') === String(nodeKey)) return row
626
+ if (row.getAttribute('data-chat-node-key') === String(nodeKey)) exact.push(row)
477
627
  }
478
628
  }
479
- if (!messageId) return null
480
- const id = String(messageId)
481
- for (const row of rows) {
482
- const key = row.getAttribute('data-chat-node-key') || ''
483
- if (key === id || key.endsWith(id)) return row
629
+ if (exact.length === 0 && messageId) {
630
+ const id = String(messageId)
631
+ for (const row of rows) {
632
+ const key = row.getAttribute('data-chat-node-key') || ''
633
+ if (key === id || key.endsWith(id)) exact.push(row)
634
+ }
484
635
  }
485
- return null
636
+ // A single assistant step can be rendered as a reasoning row and a
637
+ // response row under the same node key; prefer the response row.
638
+ return exact.sort((left, right) => {
639
+ const score = row => row.getAttribute('data-chat-group-part') === 'response' ? 0 : 1
640
+ return score(left) - score(right)
641
+ })
486
642
  }
487
643
 
488
644
  function needlesFromQuery(query) {
@@ -492,57 +648,173 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
492
648
  return [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 8)
493
649
  }
494
650
 
495
- function inlineGroups(root) {
496
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
651
+ function projectGroups(root, excluded) {
652
+ if (root === null) return []
497
653
  const groups = new Map()
654
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
655
+ acceptNode(node) {
656
+ if (node.nodeType === Node.ELEMENT_NODE) {
657
+ if (node.matches(excluded)) return NodeFilter.FILTER_REJECT
658
+ if (node.tagName === 'BR') return NodeFilter.FILTER_ACCEPT
659
+ return NodeFilter.FILTER_SKIP
660
+ }
661
+ return NodeFilter.FILTER_ACCEPT
662
+ },
663
+ })
664
+ const groupFor = block => {
665
+ let group = groups.get(block)
666
+ if (group === undefined) {
667
+ group = {
668
+ block, raw: '', rawLength: 0, text: '', runs: [], nodes: [], breaks: [],
669
+ collapse: !(block instanceof Element && block.tagName === 'PRE'),
670
+ }
671
+ groups.set(block, group)
672
+ }
673
+ return group
674
+ }
675
+ const appendText = (group, node) => {
676
+ const value = node.nodeValue || ''
677
+ if (value === '') return
678
+ group.nodes.push({ node, start: group.raw.length, end: group.raw.length + value.length })
679
+ group.raw += value
680
+ }
681
+ const appendBreak = group => {
682
+ group.breaks.push(group.raw.length)
683
+ group.raw += '\u0000'
684
+ }
498
685
  let node = walker.nextNode()
499
686
  while (node !== null) {
500
687
  const parent = node.parentElement
501
- if (parent !== null && parent.closest(EXCLUDED) === null) {
502
- const value = node.nodeValue || ''
503
- if (value !== '') {
504
- const block = parent.closest(BLOCK_SELECTOR) || root
505
- let group = groups.get(block)
506
- if (group === undefined) { group = { nodes: [], text: '' }; groups.set(block, group) }
507
- group.nodes.push(node)
508
- group.text += value
688
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
689
+ if (parent !== null && parent.closest(excluded) === null) {
690
+ const block = parent.closest(BLOCK_SELECTOR)
691
+ || parent.closest('[data-text-interaction-source]')
692
+ || parent.closest('[data-chat-node-key]')
693
+ || root
694
+ appendBreak(groupFor(block))
509
695
  }
696
+ } else if (parent !== null && parent.closest(excluded) === null) {
697
+ const block = parent.closest(BLOCK_SELECTOR)
698
+ || parent.closest('[data-text-interaction-source]')
699
+ || parent.closest('[data-chat-node-key]')
700
+ || root
701
+ appendText(groupFor(block), node)
510
702
  }
511
703
  node = walker.nextNode()
512
704
  }
705
+ // Project the concatenated raw text once per block. Soft newlines and
706
+ // repeated whitespace become one space outside <pre>; <br> becomes a
707
+ // separator. rums maps projected offsets back to raw offsets without
708
+ // allocating one object per character (a multi-MB transcript would
709
+ // otherwise create millions of position objects).
710
+ for (const group of groups.values()) {
711
+ const raw = group.raw
712
+ group.rawLength = raw.length
713
+ if (!group.collapse) {
714
+ group.text = raw.replace(/\u0000/g, '\n')
715
+ group.runs = []
716
+ continue
717
+ }
718
+ let text = ''
719
+ const runs = []
720
+ let index = 0
721
+ while (index < raw.length) {
722
+ const char = raw[index]
723
+ if (char === '\u0000' || /\s/u.test(char)) {
724
+ const start = index
725
+ while (index < raw.length && (raw[index] === '\u0000' || /\s/u.test(raw[index]))) index++
726
+ runs.push({ rawStart: start, rawLength: index - start, projectedStart: text.length })
727
+ text += ' '
728
+ } else {
729
+ text += char
730
+ index++
731
+ }
732
+ }
733
+ group.text = text
734
+ group.runs = runs
735
+ }
513
736
  return [...groups.values()]
514
737
  }
515
738
 
516
- function offsetInGroup(group, index) {
517
- let consumed = 0
518
- for (const node of group.nodes) {
519
- const length = (node.nodeValue || '').length
520
- if (index <= consumed + length) return [node, index - consumed]
521
- consumed += length
739
+ function inlineGroups(root) {
740
+ return projectGroups(root, EXCLUDED)
741
+ }
742
+
743
+ function projectedToRaw(group, index, isEnd) {
744
+ if (!group.collapse) return Math.max(0, Math.min(group.rawLength, index))
745
+ let extra = 0
746
+ for (const run of group.runs) {
747
+ if (run.projectedStart < index || (isEnd && run.projectedStart === index)) extra += run.rawLength - 1
748
+ else break
749
+ }
750
+ return Math.max(0, Math.min(group.rawLength, index + extra))
751
+ }
752
+
753
+ function rawToProjected(group, raw) {
754
+ if (!group.collapse) return Math.max(0, Math.min(group.rawLength, raw))
755
+ let extra = 0
756
+ for (const run of group.runs) {
757
+ const end = run.rawStart + run.rawLength
758
+ if (raw >= end) extra += run.rawLength - 1
759
+ else if (raw > run.rawStart) return run.projectedStart
760
+ else break
761
+ }
762
+ return Math.max(0, Math.min(group.rawLength, raw - extra))
763
+ }
764
+
765
+ function pointForRaw(group, raw, isEnd) {
766
+ for (const entry of group.nodes) {
767
+ if (raw < entry.start) continue
768
+ if (raw <= entry.end) return { node: entry.node, offset: Math.min(raw - entry.start, entry.end - entry.start) }
769
+ }
770
+ for (const br of group.breaks) {
771
+ if (raw !== br) continue
772
+ if (isEnd) {
773
+ for (let i = group.nodes.length - 1; i >= 0; i--) {
774
+ const entry = group.nodes[i]
775
+ if (entry.end <= br) return { node: entry.node, offset: entry.end - entry.start }
776
+ }
777
+ } else {
778
+ for (const entry of group.nodes) {
779
+ if (entry.start > br) return { node: entry.node, offset: 0 }
780
+ }
781
+ }
522
782
  }
523
783
  const last = group.nodes[group.nodes.length - 1]
524
- return [last, (last.nodeValue || '').length]
784
+ return last === undefined ? null : { node: last.node, offset: last.end - last.start }
785
+ }
786
+
787
+ function positionAt(group, index, isEnd) {
788
+ const raw = projectedToRaw(group, index, isEnd)
789
+ return pointForRaw(group, raw, isEnd)
790
+ }
791
+
792
+ function rangeAt(group, start, end) {
793
+ const from = positionAt(group, start, false)
794
+ const to = positionAt(group, end, true)
795
+ if (from === null || to === null) return null
796
+ const range = document.createRange()
797
+ try {
798
+ range.setStart(from.node, from.offset)
799
+ range.setEnd(to.node, to.offset)
800
+ return range
801
+ } catch {
802
+ return null
803
+ }
525
804
  }
526
805
 
527
806
  function rangesForNeedles(root, needles) {
528
807
  const ranges = []
529
808
  if (root === null || needles.length === 0) return ranges
809
+ const patterns = needles.map(escapeRegExp)
810
+ const expression = new RegExp(patterns.join('|'), 'giu')
530
811
  for (const group of inlineGroups(root)) {
531
- const lower = group.text.toLowerCase()
532
- for (const needle of needles) {
533
- const target = needle.toLowerCase()
534
- let from = 0
535
- while (ranges.length < 200) {
536
- const index = lower.indexOf(target, from)
537
- if (index < 0) break
538
- const start = offsetInGroup(group, index)
539
- const end = offsetInGroup(group, index + target.length)
540
- const range = document.createRange()
541
- range.setStart(start[0], start[1])
542
- range.setEnd(end[0], end[1])
543
- ranges.push(range)
544
- from = index + target.length
545
- }
812
+ expression.lastIndex = 0
813
+ let match
814
+ while (ranges.length < 200 && (match = expression.exec(group.text)) !== null) {
815
+ if (match[0].length === 0) { expression.lastIndex++; continue }
816
+ const range = rangeAt(group, match.index, match.index + match[0].length)
817
+ if (range !== null) ranges.push(range)
546
818
  }
547
819
  }
548
820
  return ranges
@@ -575,16 +847,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
575
847
  function phraseRange(root, snippet) {
576
848
  if (root === null) return null
577
849
  for (const phrase of phraseCandidates(snippet)) {
578
- const needle = phrase.toLowerCase()
850
+ const expression = new RegExp(escapeRegExp(phrase), 'iu')
579
851
  for (const group of inlineGroups(root)) {
580
- const index = group.text.toLowerCase().indexOf(needle)
581
- if (index < 0) continue
582
- const start = offsetInGroup(group, index)
583
- const end = offsetInGroup(group, index + needle.length)
584
- const range = document.createRange()
585
- range.setStart(start[0], start[1])
586
- range.setEnd(end[0], end[1])
587
- return range
852
+ const match = expression.exec(group.text)
853
+ if (match === null) continue
854
+ const range = rangeAt(group, match.index, match.index + match[0].length)
855
+ if (range !== null) return range
588
856
  }
589
857
  }
590
858
  return null
@@ -681,7 +949,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
681
949
  let activeHighlight = null
682
950
 
683
951
  function clearJumpHighlight() {
684
- clearFindHighlight()
952
+ clearFindHighlight('jump')
685
953
  if (activeHighlight !== null) {
686
954
  const clear = activeHighlight
687
955
  activeHighlight = null
@@ -708,15 +976,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
708
976
  if (flow === null) { showNotice(t('jumpFailed')); return false }
709
977
  const needles = needlesFromQuery(query)
710
978
  const finder = () => {
711
- const row = findMessageRow(flow, hit.messageId, hit.nodeKey)
712
- if (row !== null) {
713
- expandHidden(row)
714
- const ranges = rangesForNeedles(row, needles)
715
- if (ranges.length > 0) return { row, ranges }
716
- const phrase = phraseRange(row, hit.snippet)
717
- if (phrase !== null) return { row, ranges: [phrase] }
979
+ const rows = findMessageRows(flow, hit.messageId, hit.nodeKey)
980
+ if (rows.length > 0) {
981
+ for (const row of rows) {
982
+ expandHidden(row)
983
+ const ranges = rangesForNeedles(row, needles)
984
+ if (ranges.length > 0) return { row, ranges }
985
+ const phrase = phraseRange(row, hit.snippet)
986
+ if (phrase !== null) {
987
+ // Keep the phrase range inside the anchored row instead of
988
+ // falling through to a wrong copy elsewhere in the transcript.
989
+ const inside = row.contains(phrase.commonAncestorContainer)
990
+ if (inside) return { row, ranges: [phrase] }
991
+ }
992
+ }
718
993
  return null
719
994
  }
995
+ // With an exact node key there is nothing safe to fall back to in the
996
+ // already loaded window; wait for older pages instead of landing on a
997
+ // similar phrase in a different message.
998
+ if (hit.nodeKey || hit.messageId) return null
720
999
  const phrase = phraseRange(flow, hit.snippet)
721
1000
  if (phrase === null) return null
722
1001
  expandHidden(phrase.startContainer)
@@ -729,13 +1008,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
729
1008
  let target = await waitFor(flow, finder, 6_000, 120)
730
1009
  for (let attempt = 0; target === null && attempt < 10; attempt++) {
731
1010
  if (stale()) return false
732
- for (const hidden of flow.querySelectorAll('[hidden="until-found"]')) hidden.dispatchEvent(new Event('beforematch'))
733
1011
  if (!revealOlder(scope)) break
734
1012
  target = await waitFor(flow, finder, 1_500, 150)
735
1013
  }
736
1014
  if (stale()) return false
737
1015
  if (target === null) { showNotice(t('phraseNotFound')); return false }
738
- setFindHighlight(target.ranges)
1016
+ setFindHighlight(target.ranges, 0, 'jump')
739
1017
  centerRange(target.ranges[0])
740
1018
  const start = target.ranges[0].startContainer
741
1019
  const chain = scrollChain(start instanceof Element ? start : start.parentElement)
@@ -753,7 +1031,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
753
1031
  const fn = highlightCleanup.pop()
754
1032
  try { fn() } catch { /* ignore */ }
755
1033
  }
756
- clearFindHighlight()
1034
+ clearFindHighlight('jump')
757
1035
  }
758
1036
  // Cancelling must drop the scroll lock too, not only the paint: the lock
759
1037
  // owns timers and scroll/pointer listeners that would otherwise outlive
@@ -799,7 +1077,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
799
1077
  const next = finder()
800
1078
  if (next === null) { cancelJump(); return }
801
1079
  target = next
802
- setFindHighlight(target.ranges)
1080
+ setFindHighlight(target.ranges, 0, 'jump')
803
1081
  centerRange(target.ranges[0])
804
1082
  }, 1_000)
805
1083
  highlightCleanup.push(() => clearInterval(sessionWatch))
@@ -812,6 +1090,641 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
812
1090
  const PAGE_NAME = 50
813
1091
  const PAGE_CONTENT = 30
814
1092
 
1093
+ // ── in-conversation find bar ────────────────────────────────────────────
1094
+ const FIND_MAX_MATCHES = 2000
1095
+ const FIND_MAX_CHARS = 4_000_000
1096
+ const FIND_BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
1097
+ const FIND_EXCLUDED = '[hidden]:not([hidden="until-found"]),[data-chat-history],[data-text-interaction-surface],'
1098
+ + '[data-composer-seat],[data-text-interaction-exclude],script,style,button,input,textarea,select,'
1099
+ + '.sr-only,[class*="visuallyHidden"],[role="status"],[aria-live],.katex-mathml,[aria-hidden="true"]:not(.katex-html)'
1100
+
1101
+ function createFindStore() {
1102
+ const listeners = new Set()
1103
+ let state = {
1104
+ open: false, sessionId: '', scope: 'session', selection: null, query: '',
1105
+ mode: 'normal', caseSensitive: false, wholeWord: false, wrap: true, direction: 1,
1106
+ optionsOpen: false, count: 0, index: -1, capped: false, status: 'idle', error: '', hint: '',
1107
+ }
1108
+ return {
1109
+ get: () => state,
1110
+ subscribe(listener) {
1111
+ listeners.add(listener)
1112
+ return () => listeners.delete(listener)
1113
+ },
1114
+ set(patch) {
1115
+ state = { ...state, ...patch }
1116
+ for (const listener of listeners) listener()
1117
+ },
1118
+ }
1119
+ }
1120
+ const findStore = createFindStore()
1121
+ const findApi = { move: () => false, cancel: () => {}, focus: () => {} }
1122
+ function useFindState() {
1123
+ return useSyncExternalStore(findStore.subscribe, findStore.get, findStore.get)
1124
+ }
1125
+
1126
+ function selectionSnapshot(scope) {
1127
+ if (scope === null || typeof window.getSelection !== 'function') return null
1128
+ const selection = window.getSelection()
1129
+ if (selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return null
1130
+ const range = selection.getRangeAt(0).cloneRange()
1131
+ if (!scope.contains(range.commonAncestorContainer)) return null
1132
+ const startEl = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
1133
+ if (startEl !== null && startEl.closest('[data-composer-seat]') !== null) return null
1134
+ const text = String(selection.toString() || '').replace(/\s+/g, ' ').trim()
1135
+ if (text === '') return null
1136
+ return { range, text: text.slice(0, 500), chars: text.length }
1137
+ }
1138
+
1139
+ function openFind(options) {
1140
+ const opts = options || {}
1141
+ let scope = opts.sessionId ? conversationScope(opts.sessionId) : null
1142
+ if (scope === null) scope = conversationScope('')
1143
+ if (scope === null) { showNotice(t('findNoSession')); return false }
1144
+ const sessionId = scope.getAttribute('data-conversation-session') || opts.sessionId || ''
1145
+ const selection = opts.selection === false ? null : selectionSnapshot(scope)
1146
+ const useSelection = opts.selection === true && selection !== null
1147
+ findApi.cancel()
1148
+ cancelJump()
1149
+ if (store.isOpen()) store.set(false)
1150
+ findStore.set({
1151
+ open: true, sessionId, scope: useSelection ? 'selection' : 'session',
1152
+ selection: useSelection ? selection : null, query: '', mode: 'normal',
1153
+ caseSensitive: false, wholeWord: false, wrap: true, direction: 1,
1154
+ optionsOpen: false, count: 0, index: -1, capped: false, status: 'idle', error: '', hint: '',
1155
+ })
1156
+ return true
1157
+ }
1158
+
1159
+ function closeFind() {
1160
+ if (!findStore.get().open) return
1161
+ findStore.set({ open: false, selection: null, query: '', error: '', hint: '', status: 'idle' })
1162
+ findApi.cancel()
1163
+ clearFindHighlight('find')
1164
+ setFindScope(null)
1165
+ }
1166
+
1167
+ const FIND_WORKER_SRC = `
1168
+ self.onmessage = event => {
1169
+ const { id, pattern, flags, texts } = event.data
1170
+ try {
1171
+ const re = new RegExp(pattern, flags)
1172
+ const out = []
1173
+ for (let ti = 0; ti < texts.length; ti++) {
1174
+ const text = texts[ti]
1175
+ re.lastIndex = 0
1176
+ let match
1177
+ while ((match = re.exec(text)) !== null) {
1178
+ if (match[0].length === 0) { re.lastIndex++; continue }
1179
+ out.push([ti, match.index, match.index + match[0].length])
1180
+ if (out.length >= 2000) break
1181
+ }
1182
+ if (out.length >= 2000) break
1183
+ }
1184
+ self.postMessage({ id, ok: true, matches: out, capped: out.length >= 2000 })
1185
+ } catch (error) {
1186
+ self.postMessage({ id, ok: false, error: String(error && error.message || error) })
1187
+ }
1188
+ }
1189
+ `
1190
+
1191
+ function scanRegexInWorker(texts, source, flags) {
1192
+ if (typeof Worker !== 'function' || typeof Blob !== 'function' || typeof URL.createObjectURL !== 'function') {
1193
+ return Promise.resolve(null)
1194
+ }
1195
+ return new Promise(resolve => {
1196
+ let worker = null
1197
+ let url = ''
1198
+ let done = false
1199
+ const finish = value => {
1200
+ if (done) return
1201
+ done = true
1202
+ clearTimeout(timer)
1203
+ try { if (worker !== null) worker.terminate() } catch { /* ignore */ }
1204
+ try { if (url !== '') URL.revokeObjectURL(url) } catch { /* ignore */ }
1205
+ resolve(value)
1206
+ }
1207
+ const timer = setTimeout(() => finish({ timeout: true }), 500)
1208
+ try {
1209
+ const blob = new Blob([FIND_WORKER_SRC], { type: 'text/javascript' })
1210
+ url = URL.createObjectURL(blob)
1211
+ worker = new Worker(url)
1212
+ worker.onmessage = event => finish(event.data)
1213
+ worker.onerror = () => finish(null)
1214
+ worker.postMessage({ id: 1, pattern: source, flags, texts })
1215
+ } catch {
1216
+ finish(null)
1217
+ }
1218
+ })
1219
+ }
1220
+
1221
+ function expandExtended(input) {
1222
+ let out = ''
1223
+ for (let index = 0; index < input.length; index++) {
1224
+ const char = input[index]
1225
+ if (char !== '\\') { out += char; continue }
1226
+ const next = input[index + 1]
1227
+ if (next === undefined) { out += '\\'; continue }
1228
+ index++
1229
+ if (next === 'n') out += '\n'
1230
+ else if (next === 'r') out += '\r'
1231
+ else if (next === 't') out += '\t'
1232
+ else if (next === '0') out += '\u0000'
1233
+ else if (next === '\\') out += '\\'
1234
+ else if (next === 'x') {
1235
+ const hex = input.slice(index + 1, index + 3)
1236
+ if (!/^[0-9a-fA-F]{2}$/u.test(hex)) return { error: '\\x' }
1237
+ out += String.fromCharCode(parseInt(hex, 16))
1238
+ index += 2
1239
+ } else if (next === 'u') {
1240
+ const hex = input.slice(index + 1, index + 5)
1241
+ if (!/^[0-9a-fA-F]{4}$/u.test(hex)) return { error: '\\u' }
1242
+ out += String.fromCharCode(parseInt(hex, 16))
1243
+ index += 4
1244
+ } else out += '\\' + next
1245
+ }
1246
+ return { value: out }
1247
+ }
1248
+
1249
+ function compileFind(query, mode, caseSensitive) {
1250
+ let source = String(query || '')
1251
+ if (mode === 'extended') {
1252
+ const expanded = expandExtended(source)
1253
+ if (expanded.error !== undefined) return { error: t('findInvalidEscape', { token: expanded.error }) }
1254
+ source = expanded.value
1255
+ }
1256
+ if (mode !== 'regex') source = escapeRegExp(source)
1257
+ const flags = 'g' + (caseSensitive ? '' : 'i')
1258
+ // Prefer the Unicode flag so \p{...}, astral literals and modern syntax
1259
+ // behave as users expect. Fall back once for legacy patterns that the
1260
+ // unicode grammar rejects (for example some lone escapes).
1261
+ try {
1262
+ return { source, flags: flags + 'u', re: new RegExp(source, flags + 'u') }
1263
+ } catch (error) {
1264
+ try {
1265
+ return { source, flags, re: new RegExp(source, flags) }
1266
+ } catch (fallbackError) {
1267
+ return { error: t('findInvalidRegex', { message: String(fallbackError && fallbackError.message || fallbackError).slice(0, 140) }) }
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ function isWordCharacter(char) {
1273
+ return typeof char === 'string' && char !== '' && /^[A-Za-z0-9_]$/u.test(char)
1274
+ }
1275
+
1276
+ function wholeWordAt(text, start, end) {
1277
+ return !isWordCharacter(text[start - 1]) && !isWordCharacter(text[end])
1278
+ }
1279
+
1280
+ function collectFindGroups(root) {
1281
+ return projectGroups(root, FIND_EXCLUDED)
1282
+ }
1283
+
1284
+ function clipForGroup(group, range) {
1285
+ if (range === null) return null
1286
+ let rawStart = null
1287
+ let rawEnd = null
1288
+ for (const entry of group.nodes) {
1289
+ const node = entry.node
1290
+ const length = entry.end - entry.start
1291
+ try {
1292
+ if (!range.intersectsNode(node)) continue
1293
+ } catch { continue }
1294
+ let nodeStart = 0
1295
+ let nodeEnd = length
1296
+ if (node === range.startContainer) nodeStart = range.startOffset
1297
+ if (node === range.endContainer) nodeEnd = range.endOffset
1298
+ if (nodeStart < nodeEnd) {
1299
+ if (rawStart === null) rawStart = entry.start + nodeStart
1300
+ rawEnd = entry.start + nodeEnd
1301
+ }
1302
+ }
1303
+ if (rawStart === null || rawEnd === null) return null
1304
+ const start = rawToProjected(group, rawStart)
1305
+ const end = rawToProjected(group, rawEnd)
1306
+ if (start >= end) return null
1307
+ return [start, end]
1308
+ }
1309
+
1310
+ function rangeForGroupOffsets(group, start, end) {
1311
+ if (group.nodes.length === 0) return null
1312
+ return rangeAt(group, start, end)
1313
+ }
1314
+
1315
+ function findHistoryButton(scope) {
1316
+ return scope === null ? null : scope.querySelector('[data-chat-history] button')
1317
+ }
1318
+
1319
+ function FindToggle(props) {
1320
+ const onClick = () => { openFind({ sessionId: props && props.sessionId ? props.sessionId : '', selection: false }) }
1321
+ return h('button', {
1322
+ className: 'dsh-locator-launch',
1323
+ title: t('findOpen') + ' (Ctrl+F)',
1324
+ 'aria-label': t('findOpen'),
1325
+ onClick,
1326
+ }, '⌕')
1327
+ }
1328
+
1329
+ function FindBar() {
1330
+ const state = useFindState()
1331
+ const inputRef = useRef(null)
1332
+ const barRef = useRef(null)
1333
+ const scopeRef = useRef(null)
1334
+ const matchesRef = useRef([])
1335
+ const activeRef = useRef(-1)
1336
+ const sequenceRef = useRef(0)
1337
+ const debounceRef = useRef(0)
1338
+ const observerRef = useRef(null)
1339
+ const revealTimersRef = useRef([])
1340
+ const revealFrameRef = useRef(0)
1341
+ const loadOlderTimerRef = useRef(0)
1342
+ const returnFocusRef = useRef(null)
1343
+ const [anchor, setAnchor] = useState(null)
1344
+ const composingRef = useRef(false)
1345
+
1346
+ const applyHighlight = useCallback((index) => {
1347
+ const matches = matchesRef.current
1348
+ if (matches.length === 0) { clearFindHighlight('find'); setFindScope(null); return }
1349
+ const safe = Math.max(0, Math.min(matches.length - 1, index))
1350
+ activeRef.current = safe
1351
+ setFindHighlight(matches.map(match => match.range), safe, 'find')
1352
+ setFindScope(state.scope === 'selection' && state.selection ? state.selection.range : null)
1353
+ }, [state.scope, state.selection])
1354
+
1355
+ const revealActive = useCallback(() => {
1356
+ const match = matchesRef.current[activeRef.current]
1357
+ if (!match || !match.range.startContainer.isConnected) return false
1358
+ expandHidden(match.range.startContainer)
1359
+ const run = () => {
1360
+ if (!match.range.startContainer.isConnected) return
1361
+ centerRange(match.range)
1362
+ }
1363
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(revealFrameRef.current)
1364
+ revealFrameRef.current = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : 0
1365
+ if (revealFrameRef.current === 0) run()
1366
+ for (const delay of [80, 240]) {
1367
+ const timer = setTimeout(run, delay)
1368
+ revealTimersRef.current.push(timer)
1369
+ }
1370
+ return true
1371
+ }, [])
1372
+
1373
+ const move = useCallback(delta => {
1374
+ const matches = matchesRef.current
1375
+ if (matches.length === 0) return false
1376
+ const direction = delta === 0 ? (findStore.get().direction === -1 ? -1 : 1) : delta
1377
+ if (!findStore.get().wrap && (activeRef.current + direction >= matches.length || activeRef.current + direction < 0)) {
1378
+ findStore.set({ hint: direction > 0 ? t('findNoWrapEnd') : t('findNoWrapStart') })
1379
+ return false
1380
+ }
1381
+ let next = activeRef.current + direction
1382
+ if (next >= matches.length) next = 0
1383
+ if (next < 0) next = matches.length - 1
1384
+ const wrapped = (activeRef.current + direction >= matches.length) || (activeRef.current + direction < 0)
1385
+ activeRef.current = next
1386
+ applyHighlight(next)
1387
+ findStore.set({
1388
+ index: next,
1389
+ hint: wrapped ? (direction > 0 ? t('findWrappedTop') : t('findWrappedBottom')) : '',
1390
+ })
1391
+ revealActive()
1392
+ return true
1393
+ }, [applyHighlight, revealActive])
1394
+
1395
+ const runScan = useCallback(async (keepActive) => {
1396
+ const token = ++sequenceRef.current
1397
+ const scope = scopeRef.current || conversationScope(state.sessionId) || conversationScope('')
1398
+ if (scope === null) { return }
1399
+ scopeRef.current = scope
1400
+ if (state.query === '') {
1401
+ matchesRef.current = []
1402
+ activeRef.current = -1
1403
+ clearFindHighlight('find')
1404
+ setFindScope(state.scope === 'selection' && state.selection ? state.selection.range : null)
1405
+ findStore.set({ count: 0, index: -1, capped: false, status: 'idle', error: '', hint: '' })
1406
+ return
1407
+ }
1408
+ const compiled = compileFind(state.query, state.mode, state.caseSensitive)
1409
+ if (compiled.error !== undefined) {
1410
+ matchesRef.current = []
1411
+ activeRef.current = -1
1412
+ clearFindHighlight('find')
1413
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: compiled.error })
1414
+ return
1415
+ }
1416
+ let selectionRange = null
1417
+ if (state.scope === 'selection' && state.selection) {
1418
+ selectionRange = state.selection.range
1419
+ let selectionDead = !selectionRange.startContainer.isConnected || selectionRange.collapsed
1420
+ if (!selectionDead) {
1421
+ try { selectionDead = selectionRange.toString().trim() === '' } catch { selectionDead = true }
1422
+ }
1423
+ if (selectionDead) {
1424
+ findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1425
+ return
1426
+ }
1427
+ }
1428
+ const groups = collectFindGroups(scope)
1429
+ const texts = []
1430
+ const clips = []
1431
+ let chars = 0
1432
+ for (const group of groups) {
1433
+ const clip = selectionRange === null ? null : clipForGroup(group, selectionRange)
1434
+ if (selectionRange !== null && clip === null) { texts.push(null); clips.push(null); continue }
1435
+ const start = clip === null ? 0 : clip[0]
1436
+ const end = clip === null ? group.text.length : clip[1]
1437
+ const text = group.text.slice(start, end)
1438
+ chars += text.length
1439
+ if (chars > FIND_MAX_CHARS) { texts.push(null); clips.push(null); continue }
1440
+ texts.push(text)
1441
+ clips.push(clip === null ? [0, group.text.length] : clip)
1442
+ }
1443
+ if (selectionRange !== null && clips.every(clip => clip === null)) {
1444
+ findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1445
+ return
1446
+ }
1447
+ let rawMatches = null
1448
+ let capped = false
1449
+ if (state.mode === 'regex') {
1450
+ try {
1451
+ const result = await scanRegexInWorker(texts.filter(text => text !== null), compiled.source, compiled.flags)
1452
+ if (token !== sequenceRef.current) return
1453
+ if (result === null) {
1454
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findRegexUnavailable') })
1455
+ return
1456
+ }
1457
+ if (result !== null) {
1458
+ if (result.timeout === true) {
1459
+ matchesRef.current = []
1460
+ clearFindHighlight('find')
1461
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findTimeout') })
1462
+ return
1463
+ }
1464
+ if (result.ok === false) {
1465
+ matchesRef.current = []
1466
+ clearFindHighlight('find')
1467
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findInvalidRegex', { message: String(result.error || '') }) })
1468
+ return
1469
+ }
1470
+ const compact = []
1471
+ let cursor = 0
1472
+ for (let groupIndex = 0; groupIndex < texts.length; groupIndex++) {
1473
+ if (texts[groupIndex] === null) continue
1474
+ compact.push(groupIndex)
1475
+ cursor++
1476
+ }
1477
+ rawMatches = []
1478
+ for (const item of result.matches) {
1479
+ const groupIndex = compact[item[0]]
1480
+ if (groupIndex === undefined) continue
1481
+ rawMatches.push({ groupIndex, start: item[1] + clips[groupIndex][0], end: item[2] + clips[groupIndex][0] })
1482
+ }
1483
+ capped = result.capped === true
1484
+ }
1485
+ } catch { /* fall back below */ }
1486
+ }
1487
+ if (rawMatches === null) {
1488
+ rawMatches = []
1489
+ const re = new RegExp(compiled.source, compiled.flags)
1490
+ const started = performance.now()
1491
+ for (let groupIndex = 0; groupIndex < texts.length; groupIndex++) {
1492
+ const text = texts[groupIndex]
1493
+ if (text === null) continue
1494
+ re.lastIndex = 0
1495
+ let match
1496
+ while ((match = re.exec(text)) !== null) {
1497
+ if (match[0].length === 0) { re.lastIndex++; continue }
1498
+ const start = clips[groupIndex][0] + match.index
1499
+ const end = start + match[0].length
1500
+ if (state.wholeWord && !wholeWordAt(groups[groupIndex].text, start, end)) continue
1501
+ rawMatches.push({ groupIndex, start, end })
1502
+ if (rawMatches.length >= FIND_MAX_MATCHES) break
1503
+ }
1504
+ if (rawMatches.length >= FIND_MAX_MATCHES) break
1505
+ if (performance.now() - started > 1500) { capped = true; break }
1506
+ }
1507
+ capped = capped || rawMatches.length >= FIND_MAX_MATCHES
1508
+ } else if (state.wholeWord) {
1509
+ rawMatches = rawMatches.filter(raw => {
1510
+ const group = groups[raw.groupIndex]
1511
+ return group !== undefined && wholeWordAt(group.text, raw.start, raw.end)
1512
+ })
1513
+ capped = capped || rawMatches.length >= FIND_MAX_MATCHES
1514
+ }
1515
+ if (token !== sequenceRef.current) return
1516
+ const matches = []
1517
+ for (const raw of rawMatches) {
1518
+ const group = groups[raw.groupIndex]
1519
+ if (group === undefined) continue
1520
+ const range = rangeForGroupOffsets(group, raw.start, raw.end)
1521
+ if (range === null) continue
1522
+ const row = group.block.closest('[data-chat-node-key]')
1523
+ matches.push({ range, rowKey: row === null ? '' : (row.getAttribute('data-chat-node-key') || '') })
1524
+ }
1525
+ if (token !== sequenceRef.current) return
1526
+ matchesRef.current = matches
1527
+ const next = matches.length === 0 ? -1 : (keepActive && activeRef.current >= 0 ? Math.min(activeRef.current, matches.length - 1) : 0)
1528
+ activeRef.current = next
1529
+ const history = findHistoryButton(scopeRef.current)
1530
+ findStore.set({
1531
+ count: matches.length,
1532
+ index: next,
1533
+ capped,
1534
+ status: matches.length === 0 ? 'none' : 'idle',
1535
+ error: '',
1536
+ hint: matches.length === 0 && history !== null && !history.disabled ? t('findLoadedOnly') : '',
1537
+ })
1538
+ if (next >= 0) { applyHighlight(next); revealActive() }
1539
+ else { clearFindHighlight('find'); setFindScope(state.scope === 'selection' && state.selection ? state.selection.range : null) }
1540
+ }, [applyHighlight, revealActive, state.caseSensitive, state.mode, state.query, state.scope, state.selection, state.sessionId, state.wholeWord])
1541
+ const runScanRef = useRef(runScan)
1542
+ runScanRef.current = runScan
1543
+
1544
+ useEffect(() => {
1545
+ if (!state.open) return undefined
1546
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
1547
+ const measure = () => {
1548
+ const scope = conversationScope(findStore.get().sessionId) || conversationScope('')
1549
+ if (scope === null) { closeFind(); return }
1550
+ scopeRef.current = scope
1551
+ const rect = scope.getBoundingClientRect()
1552
+ const width = Math.max(280, Math.min(560, rect.width - 24))
1553
+ setAnchor({ top: Math.max(8, rect.top + 8), right: Math.max(8, window.innerWidth - rect.right + 12), width })
1554
+ }
1555
+ measure()
1556
+ const timer = setTimeout(() => {
1557
+ if (inputRef.current !== null) { inputRef.current.focus(); inputRef.current.select() }
1558
+ }, 0)
1559
+ const onResize = () => measure()
1560
+ window.addEventListener('resize', onResize)
1561
+ let observer = null
1562
+ const schedule = () => {
1563
+ clearTimeout(debounceRef.current)
1564
+ debounceRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 400)
1565
+ }
1566
+ const scope = scopeRef.current
1567
+ const observed = scope === null ? null : (chatFlow(scope) || scope)
1568
+ if (observed !== null) {
1569
+ try {
1570
+ observer = new MutationObserver(schedule)
1571
+ observer.observe(observed, { childList: true, subtree: true, characterData: true })
1572
+ } catch { /* observer unavailable */ }
1573
+ }
1574
+ return () => {
1575
+ clearTimeout(timer)
1576
+ window.removeEventListener('resize', onResize)
1577
+ clearTimeout(debounceRef.current)
1578
+ if (observer !== null) observer.disconnect()
1579
+ for (const id of revealTimersRef.current) clearTimeout(id)
1580
+ revealTimersRef.current = []
1581
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(revealFrameRef.current)
1582
+ clearTimeout(loadOlderTimerRef.current)
1583
+ sequenceRef.current++
1584
+ clearFindHighlight('find')
1585
+ setFindScope(null)
1586
+ const target = returnFocusRef.current
1587
+ if (target !== null && target.isConnected) target.focus({ preventScroll: true })
1588
+ }
1589
+ }, [state.open])
1590
+
1591
+ useEffect(() => {
1592
+ if (!state.open) return undefined
1593
+ const timer = setTimeout(() => { void runScan(false) }, 150)
1594
+ return () => clearTimeout(timer)
1595
+ }, [state.open, state.query, state.mode, state.caseSensitive, state.wholeWord, state.scope, state.sessionId, state.selection, runScan])
1596
+
1597
+ // Close when the conversation is switched, hidden, or the panel opens.
1598
+ useEffect(() => {
1599
+ if (!state.open) return undefined
1600
+ const timer = setInterval(() => {
1601
+ if (!findStore.get().open) return
1602
+ if (store.isOpen()) { closeFind(); return }
1603
+ if (conversationScope(findStore.get().sessionId) === null) closeFind()
1604
+ }, 800)
1605
+ return () => clearInterval(timer)
1606
+ }, [state.open])
1607
+
1608
+ useEffect(() => {
1609
+ findApi.move = delta => move(delta)
1610
+ findApi.cancel = () => { sequenceRef.current++ }
1611
+ findApi.focus = () => { if (inputRef.current !== null) { inputRef.current.focus(); inputRef.current.select() } }
1612
+ return () => { findApi.move = () => false; findApi.cancel = () => {}; findApi.focus = () => {} }
1613
+ }, [move])
1614
+
1615
+ if (!state.open) return null
1616
+ const onInput = event => { findStore.set({ query: event.target.value, error: '', hint: '' }) }
1617
+ const onKeyDown = event => {
1618
+ if (composingRef.current || event.isComposing) return
1619
+ if (event.key === 'Enter') { event.preventDefault(); move(event.shiftKey ? -1 : 0); return }
1620
+ if (event.key === 'Escape') { event.preventDefault(); closeFind(); return }
1621
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && String(event.key).toLowerCase() === 'c') {
1622
+ const match = matchesRef.current[activeRef.current]
1623
+ const text = match && match.range.startContainer.isConnected ? match.range.toString() : findStore.get().query
1624
+ if (text !== '') { event.preventDefault(); copyText(text).then(() => findStore.set({ hint: t('findCopied') })).catch(() => {}) }
1625
+ }
1626
+ }
1627
+ const pickMode = event => {
1628
+ findStore.set({ mode: event.target.value })
1629
+ if (inputRef.current !== null) inputRef.current.focus()
1630
+ }
1631
+ const toggle = key => { findStore.set({ [key]: !findStore.get()[key] }) }
1632
+ const toggleScope = () => {
1633
+ if (state.scope === 'selection') findStore.set({ scope: 'session', selection: null, hint: '' })
1634
+ else {
1635
+ const scope = scopeRef.current
1636
+ const selection = scope === null ? null : selectionSnapshot(scope)
1637
+ if (selection === null) { findStore.set({ hint: t('findPlaceholderSelection') }); return }
1638
+ findStore.set({ scope: 'selection', selection })
1639
+ }
1640
+ }
1641
+ const countText = state.query === '' ? '—'
1642
+ : (state.count === 0 ? '0/0' : (state.index < 0 ? '…' : (state.index + 1) + '/' + state.count + (state.capped ? '+' : '')))
1643
+ const history = scopeRef.current === null ? null : findHistoryButton(scopeRef.current)
1644
+ const canLoadOlder = history !== null && !history.disabled
1645
+ const loadOlder = () => {
1646
+ if (scopeRef.current === null || !revealOlder(scopeRef.current)) return
1647
+ findStore.set({ hint: t('findLoadingOlder') })
1648
+ clearTimeout(loadOlderTimerRef.current)
1649
+ loadOlderTimerRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 700)
1650
+ }
1651
+ return h('div', {
1652
+ ref: barRef,
1653
+ className: 'dsh-locator-findbar',
1654
+ role: 'search',
1655
+ 'aria-label': t('findAria'),
1656
+ style: anchor === null ? { top: 12, right: 12, width: 'min(560px,calc(100vw - 24px))' } : { top: anchor.top, right: anchor.right, width: anchor.width },
1657
+ },
1658
+ h('div', { className: 'dsh-locator-findbar-main' },
1659
+ h('span', { className: 'dsh-locator-findbar-glyph', 'aria-hidden': 'true' }, '⌕'),
1660
+ h('input', {
1661
+ ref: inputRef,
1662
+ className: 'dsh-locator-findbar-input',
1663
+ value: state.query,
1664
+ placeholder: state.scope === 'selection' ? t('findPlaceholderSelection') : t('findPlaceholder'),
1665
+ 'aria-label': t('findAria'),
1666
+ 'aria-invalid': state.error !== '' ? 'true' : undefined,
1667
+ spellCheck: false,
1668
+ autoComplete: 'off',
1669
+ onInput,
1670
+ onKeyDown,
1671
+ onCompositionStart: () => { composingRef.current = true },
1672
+ onCompositionEnd: () => { composingRef.current = false; void runScan(false) },
1673
+ }),
1674
+ h('span', { className: 'dsh-locator-findbar-count', 'aria-hidden': 'true' }, countText),
1675
+ h('span', { style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' },
1676
+ state.error !== '' ? state.error
1677
+ : state.query === '' ? ''
1678
+ : state.count === 0 ? t('findNoMatch')
1679
+ : (state.index + 1) + '/' + state.count),
1680
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findPrev'), 'aria-label': t('findPrev'),
1681
+ disabled: state.count === 0, onMouseDown: event => event.preventDefault(), onClick: () => move(-1) }, '↑'),
1682
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findNext'), 'aria-label': t('findNext'),
1683
+ disabled: state.count === 0, onMouseDown: event => event.preventDefault(), onClick: () => move(1) }, '↓'),
1684
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findClose'), 'aria-label': t('findClose'),
1685
+ onClick: () => closeFind() }, '×'),
1686
+ ),
1687
+ h('div', { className: 'dsh-locator-findbar-opts', onMouseDown: event => {
1688
+ if (event.target instanceof HTMLSelectElement) return
1689
+ event.preventDefault()
1690
+ } },
1691
+ h('label', { className: 'dsh-locator-findbar-label' }, t('findMode'),
1692
+ h('select', { value: state.mode, onChange: pickMode, 'aria-label': t('findMode') },
1693
+ h('option', { value: 'normal' }, t('modeNormal')),
1694
+ h('option', { value: 'extended' }, t('modeExtended')),
1695
+ h('option', { value: 'regex' }, t('modeRegex')),
1696
+ )),
1697
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findCase'), 'aria-label': t('findCase'),
1698
+ 'aria-pressed': state.caseSensitive ? 'true' : 'false', onClick: () => toggle('caseSensitive') }, t('panelCaseShort')),
1699
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findWhole'), 'aria-label': t('findWhole'),
1700
+ 'aria-pressed': state.wholeWord ? 'true' : 'false', onClick: () => toggle('wholeWord') }, t('panelWholeShort')),
1701
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findWrap'), 'aria-label': t('findWrap'),
1702
+ 'aria-pressed': state.wrap ? 'true' : 'false', onClick: () => toggle('wrap') }, '↻'),
1703
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findReverse'), 'aria-label': t('findReverse'),
1704
+ 'aria-pressed': state.direction === -1 ? 'true' : 'false', onClick: () => findStore.set({ direction: state.direction === -1 ? 1 : -1 }) }, '⇅'),
1705
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findScope'), 'aria-label': t('findScope'),
1706
+ 'aria-pressed': state.scope === 'selection' ? 'true' : 'false', onClick: toggleScope }, '⛶'),
1707
+ state.scope === 'selection' && state.selection
1708
+ ? h('span', { className: 'dsh-locator-findbar-chip' }, t('findScopeChip', { n: state.selection.chars }),
1709
+ h('button', { onClick: () => findStore.set({ scope: 'session', selection: null, hint: '' }), 'aria-label': t('findClose') }, '×'))
1710
+ : h('span', { className: 'dsh-locator-findbar-chip' }, t('findSessionChip')),
1711
+ state.mode === 'extended' ? h('span', { className: 'dsh-locator-findbar-label', style: { opacity: .55 } }, t('findExtendedHelp')) : null,
1712
+ ),
1713
+ state.error !== '' ? h('div', { className: 'dsh-locator-findbar-error', role: 'alert' }, state.error) : null,
1714
+ h('div', { className: 'dsh-locator-findbar-status' },
1715
+ h('span', null, state.query === '' ? t('findEmpty') : state.count === 0 ? t('findNoMatch') : (state.capped ? t('findCapped') : '')),
1716
+ state.hint !== '' ? h('span', null, state.hint) : null,
1717
+ h('span', { className: 'grow' }),
1718
+ canLoadOlder && state.query !== '' && state.count === 0
1719
+ ? h('button', { className: 'dsh-locator-dirbtn', onClick: loadOlder }, t('findOlder'))
1720
+ : null,
1721
+ h('kbd', null, 'Enter'), h('span', null, '↓'),
1722
+ h('kbd', null, 'Shift+Enter'), h('span', null, '↑'),
1723
+ h('kbd', null, 'Esc'),
1724
+ ),
1725
+ )
1726
+ }
1727
+
815
1728
  function Panel() {
816
1729
  const open = useOpen()
817
1730
  const prefs = useMemo(readPrefs, [])
@@ -825,6 +1738,10 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
825
1738
  const [took, setTook] = useState(0)
826
1739
  const [status, setStatus] = useState(null)
827
1740
  const [availableContent, setAvailableContent] = useState(true)
1741
+ const [matchMode, setMatchMode] = useState(prefs.matchMode || 'normal')
1742
+ const [caseSensitive, setCaseSensitive] = useState(prefs.caseSensitive === true)
1743
+ const [wholeWord, setWholeWord] = useState(prefs.wholeWord === true)
1744
+ const [sessionOnly, setSessionOnly] = useState(false)
828
1745
  const [selected, setSelected] = useState(0)
829
1746
  const [toast, setToast] = useState('')
830
1747
  const [refreshTick, setRefreshTick] = useState(0)
@@ -832,16 +1749,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
832
1749
  const panelRef = useRef(null)
833
1750
  const abortRef = useRef(null)
834
1751
  const listRef = useRef(null)
1752
+ const toastTimerRef = useRef(0)
1753
+ const insertTimerRef = useRef(0)
835
1754
 
836
1755
  const close = useCallback(() => store.set(false), [])
837
- const anyScope = scopes.content || scopes.files || scopes.dirs
1756
+ const activeScope = conversationScope('')
1757
+ const activeSessionId = activeScope === null ? '' : (activeScope.getAttribute('data-conversation-session') || '')
1758
+ const effectiveScopes = sessionOnly
1759
+ ? { content: scopes.content, files: false, dirs: false }
1760
+ : { content: scopes.content, files: scopes.files, dirs: scopes.dirs }
1761
+ const anyScope = effectiveScopes.content || effectiveScopes.files || effectiveScopes.dirs
838
1762
 
839
1763
  useEffect(() => {
840
- writePrefs({ scopes, sort, desc })
841
- }, [scopes, sort, desc])
1764
+ writePrefs({ scopes, sort, desc, matchMode, caseSensitive, wholeWord })
1765
+ }, [scopes, sort, desc, matchMode, caseSensitive, wholeWord])
842
1766
 
843
1767
  useEffect(() => {
844
1768
  if (!open) return undefined
1769
+ if (sessionOnly && activeSessionId === '') setSessionOnly(false)
1770
+ closeFind()
1771
+ setGroups(null)
845
1772
  const previous = document.activeElement
846
1773
  setError('')
847
1774
  const timer = setTimeout(() => {
@@ -852,9 +1779,24 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
852
1779
  }, 30)
853
1780
  return () => {
854
1781
  clearTimeout(timer)
1782
+ if (abortRef.current !== null) { abortRef.current.abort(); abortRef.current = null }
1783
+ setGroups(null)
855
1784
  if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
856
1785
  }
857
- }, [open])
1786
+ }, [open, sessionOnly, activeSessionId])
1787
+
1788
+ useEffect(() => {
1789
+ if (!open) return undefined
1790
+ const onDocumentKey = event => {
1791
+ if (event.key !== 'Escape' || event.defaultPrevented) return
1792
+ const target = event.target
1793
+ if (target instanceof Element && target.closest('.dsh-locator-panel') !== null) return
1794
+ event.preventDefault()
1795
+ close()
1796
+ }
1797
+ document.addEventListener('keydown', onDocumentKey, true)
1798
+ return () => document.removeEventListener('keydown', onDocumentKey, true)
1799
+ }, [open, close])
858
1800
 
859
1801
  useEffect(() => {
860
1802
  if (!open) return undefined
@@ -894,13 +1836,18 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
894
1836
  setError('')
895
1837
  post('/dsh-locator/combined', {
896
1838
  query,
897
- content: scopes.content,
898
- files: scopes.files,
899
- dirs: scopes.dirs,
1839
+ content: effectiveScopes.content,
1840
+ files: effectiveScopes.files,
1841
+ dirs: effectiveScopes.dirs,
900
1842
  limit: PAGE_NAME,
901
1843
  contentLimit: PAGE_CONTENT,
902
1844
  sort,
903
1845
  desc,
1846
+ matchMode,
1847
+ caseSensitive,
1848
+ wholeWord,
1849
+ sessionId: sessionOnly ? activeSessionId : '',
1850
+ count: matchMode === 'regex' ? 'none' : 'exact',
904
1851
  }, controller.signal).then(data => {
905
1852
  if (controller.signal.aborted) return
906
1853
  setGroups({
@@ -918,7 +1865,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
918
1865
  })
919
1866
  }, 140)
920
1867
  return () => clearTimeout(timer)
921
- }, [open, query, sort, desc, scopes.content, scopes.files, scopes.dirs, refreshTick, anyScope])
1868
+ }, [open, query, sort, desc, scopes.content, scopes.files, scopes.dirs, matchMode, caseSensitive, wholeWord, sessionOnly, activeSessionId, refreshTick, anyScope, effectiveScopes.content, effectiveScopes.files, effectiveScopes.dirs])
922
1869
 
923
1870
  const contentBuilding = groups !== null && groups.content !== undefined && groups.content.building === true
924
1871
  useEffect(() => {
@@ -931,20 +1878,31 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
931
1878
  if (groups === null) return []
932
1879
  const out = []
933
1880
  for (const group of ['content', 'files', 'dirs']) {
934
- if (!scopes[group]) continue
1881
+ if (!effectiveScopes[group]) continue
935
1882
  const hits = groups[group] && Array.isArray(groups[group].hits) ? groups[group].hits : []
936
1883
  for (const hit of hits) out.push({ group, hit })
937
1884
  }
938
1885
  return out
939
- }, [groups, scopes])
1886
+ }, [groups, effectiveScopes.content, effectiveScopes.files, effectiveScopes.dirs])
940
1887
 
941
1888
  useEffect(() => {
942
1889
  setSelected(value => Math.min(Math.max(0, value), Math.max(0, rows.length - 1)))
943
1890
  }, [rows.length])
944
1891
 
1892
+ useEffect(() => {
1893
+ if (!open) return
1894
+ const row = document.getElementById('dsh-locator-opt-' + selected)
1895
+ if (row !== null) row.scrollIntoView({ block: 'nearest' })
1896
+ }, [open, selected, rows.length])
1897
+
945
1898
  const notify = useCallback(message => {
946
1899
  setToast(message)
947
- setTimeout(() => setToast(''), 1600)
1900
+ clearTimeout(toastTimerRef.current)
1901
+ toastTimerRef.current = setTimeout(() => setToast(''), 1600)
1902
+ }, [])
1903
+ useEffect(() => () => {
1904
+ clearTimeout(toastTimerRef.current)
1905
+ clearTimeout(insertTimerRef.current)
948
1906
  }, [])
949
1907
 
950
1908
  const copy = useCallback(hit => {
@@ -976,7 +1934,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
976
1934
  if (!hit || hit.kind === 'session' || loading) return
977
1935
  const mention = '@"' + hit.path.replace(/"/g, '') + '" '
978
1936
  close()
979
- setTimeout(() => {
1937
+ clearTimeout(insertTimerRef.current)
1938
+ insertTimerRef.current = setTimeout(() => {
980
1939
  if (insertIntoComposer(mention)) notify(t('inserted'))
981
1940
  else copyText(mention).then(() => notify(t('insertFailed'))).catch(() => {})
982
1941
  }, 80)
@@ -1008,7 +1967,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1008
1967
  && (event.target.tagName === 'BUTTON' || event.target.tagName === 'SELECT'
1009
1968
  || (event.target.tagName === 'INPUT' && event.target.type !== 'text'))
1010
1969
  if (interactive) return
1011
- const scopeDigit = event.altKey
1970
+ const altGraph = typeof event.getModifierState === 'function' && event.getModifierState('AltGraph')
1971
+ const scopeDigit = event.altKey && !event.ctrlKey && !event.metaKey && !altGraph
1012
1972
  ? (event.code === 'Digit1' ? '1' : event.code === 'Digit2' ? '2' : event.code === 'Digit3' ? '3' : ['1', '2', '3'].includes(event.key) ? event.key : '')
1013
1973
  : ''
1014
1974
  if (scopeDigit !== '') {
@@ -1016,6 +1976,11 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1016
1976
  toggleScope(scopeDigit === '1' ? 'content' : scopeDigit === '2' ? 'files' : 'dirs')
1017
1977
  return
1018
1978
  }
1979
+ if (event.key === 'F3') {
1980
+ event.preventDefault()
1981
+ if (rows.length > 0) setSelected(value => (value + (event.shiftKey ? -1 : 1) + rows.length) % rows.length)
1982
+ return
1983
+ }
1019
1984
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1020
1985
  event.preventDefault()
1021
1986
  setSelected(value => Math.max(0, Math.min(rows.length - 1, value + (event.key === 'ArrowDown' ? 1 : -1))))
@@ -1071,28 +2036,41 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1071
2036
  const content = groups !== null ? groups.content : undefined
1072
2037
  const files = groups !== null ? groups.files : undefined
1073
2038
  const dirs = groups !== null ? groups.dirs : undefined
2039
+ const countLabel = (group, fallback) => {
2040
+ const count = group && group.count
2041
+ if (count && typeof count.value === 'number' && Number.isFinite(count.value)) {
2042
+ return (count.kind === 'bounded' ? '≥ ' : '') + String(count.value)
2043
+ }
2044
+ return String(fallback)
2045
+ }
1074
2046
  const contentCount = content === undefined ? 0 : (content.returned ?? 0)
1075
2047
  const filesCount = files === undefined ? 0 : (files.returned ?? files.hits?.length ?? 0)
1076
2048
  const dirsCount = dirs === undefined ? 0 : (dirs.returned ?? dirs.hits?.length ?? 0)
2049
+ const contentCountText = content === undefined ? '' : countLabel(content, contentCount)
2050
+ const filesCountText = files === undefined ? '' : countLabel(files, filesCount)
2051
+ const dirsCountText = dirs === undefined ? '' : countLabel(dirs, dirsCount)
1077
2052
  const totalRows = rows.length
1078
2053
 
1079
2054
  const scopeBox = (key, label, count) => h('label', {
1080
- className: 'dsh-locator-check' + (key === 'content' && !availableContent ? ' disabled' : ''),
1081
- title: key === 'content' && !availableContent ? t('contentDisabled') : label,
2055
+ className: 'dsh-locator-check' + (key === 'content' && !availableContent ? ' disabled' : '')
2056
+ + (sessionOnly && key !== 'content' ? ' disabled' : ''),
2057
+ title: key === 'content' && !availableContent ? t('contentDisabled')
2058
+ : sessionOnly && key !== 'content' ? t('panelSessionHint') : label,
1082
2059
  },
1083
2060
  h('input', {
1084
2061
  type: 'checkbox',
1085
2062
  checked: scopes[key],
1086
- disabled: key === 'content' && !availableContent,
2063
+ disabled: (key === 'content' && !availableContent) || (sessionOnly && key !== 'content'),
1087
2064
  onChange: () => toggleScope(key),
1088
2065
  }),
1089
2066
  label,
1090
- count > 0 ? h('span', { className: 'dsh-locator-count' }, String(count)) : null,
2067
+ count ? h('span', { className: 'dsh-locator-count' }, count) : null,
1091
2068
  )
1092
2069
 
1093
2070
  const groupHead = (key, label, count, note) => h('div', {
1094
2071
  className: 'dsh-locator-grouphead',
1095
2072
  id: 'dsh-locator-group-' + key,
2073
+ onMouseDown: event => event.preventDefault(),
1096
2074
  },
1097
2075
  h('span', null, label),
1098
2076
  h('span', { className: 'dsh-locator-count' }, String(count)),
@@ -1119,6 +2097,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1119
2097
  className: 'dsh-locator-row' + (isContent ? ' dsh-locator-rowcontent' : '') + (index === selected ? ' sel' : ''),
1120
2098
  title: hit.path,
1121
2099
  onClick: () => setSelected(index),
2100
+ onMouseDown: event => event.preventDefault(),
1122
2101
  onDoubleClick: () => activate(hit),
1123
2102
  },
1124
2103
  h('div', { className: 'dsh-locator-title' },
@@ -1133,29 +2112,46 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1133
2112
  )
1134
2113
  }
1135
2114
 
2115
+ const contentErrorText = code => {
2116
+ if (code === 'content-regex-not-supported') return t('panelRegexPaused')
2117
+ if (code === 'content-query-too-short') return t('contentTooShort')
2118
+ if (code === 'content-search-timeout') return t('contentTimeout')
2119
+ if (code === 'invalid-control-char') return t('contentBadChars')
2120
+ return code
2121
+ }
1136
2122
  let index = -1
1137
2123
  const renderGroup = (key, label, group, extra) => {
1138
2124
  if (!scopes[key] || group === undefined) return null
1139
2125
  const hits = Array.isArray(group.hits) ? group.hits : []
1140
2126
  const count = typeof group.returned === 'number' ? group.returned : hits.length
2127
+ const warnings = Array.isArray(group.warnings) && group.warnings.length > 0
2128
+ ? t('contentWarning', { list: group.warnings.join(', ') })
2129
+ : null
2130
+ const budget = key === 'content' && group.truncated === true ? t('contentBudget') : null
1141
2131
  const note = key === 'content'
1142
- ? (contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
1143
- : (content?.capped ? t('contentCapped', { n: count }) : (query.trim() !== '' ? t('relevance') : null)))
2132
+ ? (matchMode === 'regex' ? t('panelRegexPaused')
2133
+ : contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
2134
+ : (content?.capped ? t('contentCapped', { n: count }) : (query.trim() !== '' ? t('relevance') : null)))
1144
2135
  : (group.deduped > 0 ? t('deduped', { n: group.deduped }) : (group.truncated ? t('showing', { n: hits.length }) : null))
2136
+ const combinedNote = [note, warnings, budget].filter(value => value !== null && value !== '').join(' · ')
1145
2137
  if (typeof group.error === 'string' && group.error !== '') {
2138
+ const informational = ['content-regex-not-supported', 'content-query-too-short', 'invalid-control-char'].includes(group.error)
2139
+ const message = contentErrorText(group.error)
1146
2140
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1147
- groupHead(key, label, count, null),
1148
- h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + group.error,
1149
- h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
2141
+ groupHead(key, label, countLabel(group, count), combinedNote === '' ? null : combinedNote),
2142
+ h('div', { className: 'dsh-locator-groupempty', role: informational ? 'status' : 'alert' },
2143
+ informational ? message : t('errorPrefix') + message,
2144
+ informational ? null : h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
1150
2145
  )
1151
2146
  }
1152
2147
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1153
- groupHead(key, label, count, note),
2148
+ groupHead(key, label, countLabel(group, count), combinedNote === '' ? null : combinedNote),
1154
2149
  extra === undefined ? null : extra,
1155
2150
  hits.length === 0
1156
2151
  ? h('div', { className: 'dsh-locator-groupempty' },
1157
2152
  key === 'content' && scopes.content
1158
- ? (query.trim().length < 2 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
2153
+ ? (sessionOnly && query.trim().length >= 3 ? t('panelSessionHint')
2154
+ : query.trim().length < 3 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
1159
2155
  : (query.trim() === '' ? t('emptyQuery') : t('emptyResults')))
1160
2156
  : hits.map(hit => { index++; return rowFor(key, hit, index) }),
1161
2157
  )
@@ -1189,14 +2185,32 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1189
2185
  'aria-autocomplete': 'list',
1190
2186
  'aria-activedescendant': totalRows > 0 ? 'dsh-locator-opt-' + selected : undefined,
1191
2187
  }),
2188
+ h('span', { className: 'dsh-locator-count', 'aria-hidden': 'true' },
2189
+ loading ? t('loading') : totalRows > 0 ? t('panelTotal', { index: selected + 1, total: totalRows }) : ''),
1192
2190
  h('span', { className: 'dsh-locator-kbd' }, 'Ctrl+Shift+F'),
1193
2191
  h('button', { className: 'dsh-locator-close', onClick: close, title: t('closeTitle'), 'aria-label': t('closeLabel') }, '×'),
1194
2192
  ),
1195
2193
  h('div', { className: 'dsh-locator-bar' },
1196
- scopeBox('content', t('scopeContent'), contentCount),
1197
- scopeBox('files', t('scopeFiles'), filesCount),
1198
- scopeBox('dirs', t('scopeDirs'), dirsCount),
2194
+ scopeBox('content', t('scopeContent'), contentCountText),
2195
+ scopeBox('files', t('scopeFiles'), filesCountText),
2196
+ scopeBox('dirs', t('scopeDirs'), dirsCountText),
1199
2197
  h('span', { className: 'grow' }),
2198
+ h('label', { className: 'dsh-locator-check' + (activeSessionId === '' ? ' disabled' : ''),
2199
+ title: activeSessionId === '' ? t('findNoSession') : t('panelSessionHint') },
2200
+ h('input', { type: 'checkbox', checked: sessionOnly, disabled: activeSessionId === '',
2201
+ onChange: () => setSessionOnly(value => !value) }),
2202
+ t('panelRangeSession')),
2203
+ h('label', { className: 'dsh-locator-check' }, t('panelModeLabel'),
2204
+ h('select', { className: 'dsh-locator-sort', value: matchMode,
2205
+ onChange: event => setMatchMode(['normal', 'extended', 'regex'].includes(event.target.value) ? event.target.value : 'normal') },
2206
+ h('option', { value: 'normal' }, t('modeNormal')),
2207
+ h('option', { value: 'extended' }, t('modeExtended')),
2208
+ h('option', { value: 'regex' }, t('modeRegex')),
2209
+ )),
2210
+ h('button', { className: 'dsh-locator-dirbtn', title: t('findCase'),
2211
+ 'aria-pressed': caseSensitive ? 'true' : 'false', onClick: () => setCaseSensitive(value => !value) }, t('panelCaseShort')),
2212
+ h('button', { className: 'dsh-locator-dirbtn', title: t('findWhole'),
2213
+ 'aria-pressed': wholeWord ? 'true' : 'false', onClick: () => setWholeWord(value => !value) }, t('panelWholeShort')),
1200
2214
  h('label', { className: 'dsh-locator-check' }, t('sortLabel'),
1201
2215
  h('select', {
1202
2216
  className: 'dsh-locator-sort',
@@ -1224,6 +2238,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1224
2238
  roots: (status.roots || []).length,
1225
2239
  })),
1226
2240
  ),
2241
+ sessionOnly ? h('div', { className: 'dsh-locator-groupempty' }, t('panelSessionHint')) : null,
1227
2242
  h('div', { id: 'dsh-locator-results', className: 'dsh-locator-scroll', ref: listRef, role: 'listbox', 'aria-busy': loading ? 'true' : 'false' },
1228
2243
  error !== ''
1229
2244
  ? h('div', { className: 'dsh-locator-empty', role: 'alert' }, t('errorPrefix') + error,
@@ -1240,12 +2255,17 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1240
2255
  ],
1241
2256
  ),
1242
2257
  h('div', { className: 'dsh-locator-foot' },
1243
- h('span', null, (loading ? t('loading') + ' · ' : '') + t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: took.toFixed(1) })
2258
+ h('span', null, (loading ? t('loading') + ' · ' : '') + t('results', {
2259
+ content: contentCountText || '0',
2260
+ files: filesCountText || '0',
2261
+ dirs: dirsCountText || '0',
2262
+ ms: took.toFixed(1),
2263
+ })
1244
2264
  + (status && status.scanning ? ' · ' + t('indexing') : '')
1245
2265
  + (contentBuilding ? ' · ' + t('indexing') : '')),
1246
2266
  h('span', { className: 'grow' }),
1247
2267
  toast === '' ? null : h('span', { className: 'dsh-locator-toast' }, toast),
1248
- h('span', null, t('footer')),
2268
+ h('span', null, t('footer') + ' · Ctrl+F ' + t('findOpen')),
1249
2269
  ),
1250
2270
  h('div', { style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, role: 'status', 'aria-live': 'polite' },
1251
2271
  t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: Math.round(took) })),
@@ -1267,22 +2287,77 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1267
2287
  inject: ['slots', 'uiWorkspace'],
1268
2288
  apply(ctx) {
1269
2289
  workspaceService = ctx.uiWorkspace ?? (typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null)
1270
- ensureStyles()
2290
+ ctx.effect(() => {
2291
+ ensureStyles()
2292
+ return () => {
2293
+ const tag = document.querySelector('style[data-plugin-css="' + NS + '"]')
2294
+ if (tag !== null) tag.remove()
2295
+ }
2296
+ }, 'dsh-locator: styles')
1271
2297
  ctx.effect(() => {
1272
2298
  const onKey = event => {
1273
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f') {
1274
- event.preventDefault()
1275
- event.stopPropagation()
1276
- store.toggle()
1277
- }
2299
+ if (event.defaultPrevented || event.isComposing || event.repeat) return
2300
+ if (!((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f')) return
2301
+ const target = event.target
2302
+ if (target instanceof Element && target.closest('.xterm,.monaco-editor,[data-code-editor]') !== null) return
2303
+ const foreignModal = document.querySelector('[role="dialog"][aria-modal="true"]:not(.dsh-locator-panel)')
2304
+ if (foreignModal !== null) return
2305
+ event.preventDefault()
2306
+ event.stopPropagation()
2307
+ closeFind()
2308
+ store.toggle()
1278
2309
  }
1279
2310
  document.addEventListener('keydown', onKey, true)
1280
2311
  return () => document.removeEventListener('keydown', onKey, true)
1281
2312
  }, 'dsh-locator: search hotkey')
1282
- ctx.effect(() => () => { cancelJump() }, 'dsh-locator: find highlight')
2313
+ ctx.effect(() => {
2314
+ const onFindKey = event => {
2315
+ if (event.defaultPrevented || event.isComposing) return
2316
+ const target = event.target
2317
+ if (target instanceof Element && target.closest('.xterm,.monaco-editor,[data-code-editor]') !== null) return
2318
+ const inFind = target instanceof Element && target.closest('.dsh-locator-findbar') !== null
2319
+ const ctrl = event.ctrlKey || event.metaKey
2320
+ if (ctrl && !event.shiftKey && !event.altKey && event.code === 'KeyF') {
2321
+ if (store.isOpen()) {
2322
+ event.preventDefault()
2323
+ event.stopPropagation()
2324
+ const panelInput = document.querySelector('.dsh-locator-input')
2325
+ if (panelInput !== null) { panelInput.focus(); panelInput.select() }
2326
+ return
2327
+ }
2328
+ if (document.querySelector('[role="dialog"][aria-modal="true"]') !== null && !inFind) return
2329
+ if (findStore.get().open) { event.preventDefault(); event.stopPropagation(); findApi.focus(); return }
2330
+ if (conversationScope('') === null) return
2331
+ event.preventDefault()
2332
+ event.stopPropagation()
2333
+ openFind({ selection: true })
2334
+ return
2335
+ }
2336
+ if (!ctrl && !event.altKey && event.code === 'F3') {
2337
+ if (!findStore.get().open) return
2338
+ event.preventDefault()
2339
+ event.stopPropagation()
2340
+ findApi.move(event.shiftKey ? -1 : 1)
2341
+ return
2342
+ }
2343
+ if (event.key === 'Escape' && findStore.get().open) {
2344
+ event.preventDefault()
2345
+ event.stopPropagation()
2346
+ closeFind()
2347
+ }
2348
+ }
2349
+ document.addEventListener('keydown', onFindKey, true)
2350
+ return () => document.removeEventListener('keydown', onFindKey, true)
2351
+ }, 'dsh-locator: find hotkeys')
2352
+ ctx.effect(() => () => {
2353
+ closeFind()
2354
+ cancelJump()
2355
+ store.set(false)
2356
+ }, 'dsh-locator: find cleanup')
1283
2357
  try {
1284
2358
  ctx.slots.inject('shell.overlay', () => {
1285
2359
  const disposers = [
2360
+ ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-findbar', order: 59 }, FindBar),
1286
2361
  ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-panel', order: 60 }, Panel),
1287
2362
  ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-notice', order: 61 }, Notice),
1288
2363
  ]
@@ -1295,6 +2370,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1295
2370
  } catch (error) {
1296
2371
  console.error('dsh-locator: overlay slot unavailable', error)
1297
2372
  }
2373
+ try {
2374
+ ctx.slots.inject('conversation.session.header.utilities', () =>
2375
+ ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-locator-find-toggle', order: 30 }, FindToggle))
2376
+ } catch (error) {
2377
+ console.error('dsh-locator: session header slot unavailable', error)
2378
+ }
1298
2379
  try {
1299
2380
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-locator-launch', order: 36 }, Launcher))
1300
2381
  } catch (error) {