@bojackduy/opencode-telescope 0.1.17 → 0.1.19

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.
@@ -51,7 +51,9 @@ export const ConversationPreview = (props: { item: SearchResult; parts: Conversa
51
51
  <Show when={props.parts.length > 0} fallback={<ConversationFallback item={props.item} syntax={props.syntax} theme={props.theme} />}>
52
52
  <For each={props.parts}>
53
53
  {(part) => (
54
- <PreviewConversationPart part={part} item={props.item} syntax={props.syntax} theme={props.theme} />
54
+ <box id={`preview-part-${part.id}`} flexDirection="column" flexShrink={0}>
55
+ <PreviewConversationPart part={part} item={props.item} syntax={props.syntax} theme={props.theme} />
56
+ </box>
55
57
  )}
56
58
  </For>
57
59
  </Show>
@@ -143,8 +145,8 @@ const PreviewToolPart = (props: { part: ConversationPreviewPart; item: SearchRes
143
145
  <Show when={props.part.target}>
144
146
  <TargetMarker part={props.part} item={props.item} role={props.part.tool ?? "tool"} time={props.part.timeCreated} theme={props.theme} />
145
147
  </Show>
146
- <Show when={codeTool()} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
147
- <CodeToolPreview part={props.part} syntax={props.syntax} theme={props.theme} />
148
+ <Show when={codeTool() && props.part.target} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
149
+ <CodeToolPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
148
150
  </Show>
149
151
  <Show when={props.part.state?.error}>
150
152
  {(error) => <text fg={props.theme.error}>{error()}</text>}
@@ -167,29 +169,37 @@ const CompactToolRow = (props: { part: ConversationPreviewPart; color: any; fail
167
169
  </>
168
170
  )
169
171
 
170
- const CodeToolPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
171
- if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} syntax={props.syntax} theme={props.theme} />
172
- if (props.part.tool === "edit") return <EditPreview part={props.part} syntax={props.syntax} theme={props.theme} />
173
- if (props.part.tool === "write") return <WritePreview part={props.part} syntax={props.syntax} theme={props.theme} />
172
+ const CodeToolPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
173
+ if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
174
+ if (props.part.tool === "edit") return <EditPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
175
+ if (props.part.tool === "write") return <WritePreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
174
176
  return <CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />
175
177
  }
176
178
 
177
- const ApplyPatchPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
179
+ const ApplyPatchPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
178
180
  const files = createMemo(() => parseApplyPatchFiles(props.part.state?.metadata))
181
+ const matched = createMemo(() => {
182
+ const query = props.item.match || props.item.previewMatch
183
+ return files().find((file) => containsOrderedTokens(file.patch, query)) ?? files()[0]
184
+ })
179
185
  return (
180
- <Show when={files().length > 0} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
181
- <For each={files()}>
182
- {(file) => (
183
- <ToolBlock title={patchTitle(file)} theme={props.theme}>
184
- <DiffBlock diff={file.patch} filePath={file.filePath} syntax={props.syntax} theme={props.theme} />
186
+ <Show when={matched()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
187
+ {(file) => {
188
+ const view = createMemo(() => clippedText(file().patch, props.item.match || props.item.previewMatch, 24))
189
+ return (
190
+ <ToolBlock title={patchTitle(file())} theme={props.theme}>
191
+ <Show when={view().clipped}>
192
+ <text fg={props.theme.textMuted}>showing matched excerpt from large patch</text>
193
+ </Show>
194
+ <DiffBlock diff={view().text} filePath={file().filePath} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
185
195
  </ToolBlock>
186
- )}
187
- </For>
196
+ )
197
+ }}
188
198
  </Show>
189
199
  )
190
200
  }
191
201
 
192
- const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
202
+ const EditPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
193
203
  const input = createMemo(() => recordValue(props.part.state?.input))
194
204
  const metadata = createMemo(() => recordValue(props.part.state?.metadata))
195
205
  const diff = createMemo(() => stringValue(metadata()?.diff) ?? stringValue(recordValue(metadata()?.filediff)?.patch) ?? "")
@@ -198,14 +208,24 @@ const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle
198
208
  <Show when={diff()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
199
209
  {(value) => (
200
210
  <ToolBlock title={`← Edit ${shortPath(filePath())}`} theme={props.theme}>
201
- <DiffBlock diff={value()} filePath={filePath()} syntax={props.syntax} theme={props.theme} />
211
+ {(() => {
212
+ const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 24))
213
+ return (
214
+ <>
215
+ <Show when={view().clipped}>
216
+ <text fg={props.theme.textMuted}>showing matched excerpt from large diff</text>
217
+ </Show>
218
+ <DiffBlock diff={view().text} filePath={filePath()} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
219
+ </>
220
+ )
221
+ })()}
202
222
  </ToolBlock>
203
223
  )}
204
224
  </Show>
205
225
  )
206
226
  }
207
227
 
208
- const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
228
+ const WritePreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
209
229
  const input = createMemo(() => recordValue(props.part.state?.input))
210
230
  const filePath = createMemo(() => stringValue(input()?.filePath) ?? "")
211
231
  const content = createMemo(() => stringValue(input()?.content) ?? "")
@@ -213,15 +233,25 @@ const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyl
213
233
  <Show when={content()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
214
234
  {(value) => (
215
235
  <ToolBlock title={`# Wrote ${shortPath(filePath())}`} theme={props.theme}>
236
+ {(() => {
237
+ const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 32))
238
+ return (
239
+ <>
240
+ <Show when={view().clipped}>
241
+ <text fg={props.theme.textMuted}>showing matched excerpt from large file</text>
242
+ </Show>
216
243
  <line_number fg={props.theme.textMuted} minWidth={3} paddingRight={1}>
217
244
  <code
218
245
  conceal={false}
219
246
  fg={props.theme.text}
220
247
  filetype={filetype(filePath())}
221
248
  syntaxStyle={props.syntax}
222
- content={value()}
249
+ content={view().text}
223
250
  />
224
251
  </line_number>
252
+ </>
253
+ )
254
+ })()}
225
255
  </ToolBlock>
226
256
  )}
227
257
  </Show>
@@ -246,27 +276,29 @@ const ToolBlock = (props: { title: string; children: any; theme: TuiThemeCurrent
246
276
  </box>
247
277
  )
248
278
 
249
- const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => (
279
+ const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent; clipped?: boolean }) => (
250
280
  <box paddingLeft={1}>
251
- <diff
252
- diff={props.diff}
253
- view="unified"
254
- filetype={filetype(props.filePath)}
255
- syntaxStyle={props.syntax}
256
- showLineNumbers={true}
257
- width="100%"
258
- wrapMode="word"
259
- fg={props.theme.text}
260
- addedBg={props.theme.diffAddedBg}
261
- removedBg={props.theme.diffRemovedBg}
262
- contextBg={props.theme.diffContextBg}
263
- addedSignColor={props.theme.diffHighlightAdded}
264
- removedSignColor={props.theme.diffHighlightRemoved}
265
- lineNumberFg={props.theme.diffLineNumber}
266
- lineNumberBg={props.theme.diffContextBg}
267
- addedLineNumberBg={props.theme.diffAddedLineNumberBg}
268
- removedLineNumberBg={props.theme.diffRemovedLineNumberBg}
269
- />
281
+ <Show when={!props.clipped} fallback={<code filetype="diff" syntaxStyle={props.syntax} content={props.diff} fg={props.theme.text} />}>
282
+ <diff
283
+ diff={props.diff}
284
+ view="unified"
285
+ filetype={filetype(props.filePath)}
286
+ syntaxStyle={props.syntax}
287
+ showLineNumbers={true}
288
+ width="100%"
289
+ wrapMode="word"
290
+ fg={props.theme.text}
291
+ addedBg={props.theme.diffAddedBg}
292
+ removedBg={props.theme.diffRemovedBg}
293
+ contextBg={props.theme.diffContextBg}
294
+ addedSignColor={props.theme.diffHighlightAdded}
295
+ removedSignColor={props.theme.diffHighlightRemoved}
296
+ lineNumberFg={props.theme.diffLineNumber}
297
+ lineNumberBg={props.theme.diffContextBg}
298
+ addedLineNumberBg={props.theme.diffAddedLineNumberBg}
299
+ removedLineNumberBg={props.theme.diffRemovedLineNumberBg}
300
+ />
301
+ </Show>
270
302
  </box>
271
303
  )
272
304
 
@@ -386,6 +418,50 @@ function matchExcerpt(text: string, query: string, radius = 80) {
386
418
  }
387
419
  }
388
420
 
421
+ function clippedText(text: string, query: string, radiusLines: number) {
422
+ const lines = text.split("\n")
423
+ const tooLarge = text.length > 30000 || lines.length > 420
424
+ if (!tooLarge) return { text, clipped: false }
425
+
426
+ const matchLine = findOrderedTokenLine(lines, query)
427
+ if (matchLine === -1) {
428
+ return { text: lines.slice(0, radiusLines * 2).join("\n"), clipped: true }
429
+ }
430
+
431
+ const start = Math.max(0, matchLine - radiusLines)
432
+ const end = Math.min(lines.length, matchLine + radiusLines + 1)
433
+ return {
434
+ text: [
435
+ start > 0 ? `... ${start} lines omitted ...` : undefined,
436
+ ...lines.slice(start, end),
437
+ end < lines.length ? `... ${lines.length - end} lines omitted ...` : undefined,
438
+ ].filter(Boolean).join("\n"),
439
+ clipped: true,
440
+ }
441
+ }
442
+
443
+ function findOrderedTokenLine(lines: string[], query: string) {
444
+ const tokens = query.trim().split(/\s+/).filter(Boolean)
445
+ if (tokens.length === 0) return -1
446
+ for (let index = 0; index < lines.length; index++) {
447
+ if (containsOrderedTokens(lines[index]!, query)) return index
448
+ }
449
+ return -1
450
+ }
451
+
452
+ function containsOrderedTokens(text: string, query: string) {
453
+ const tokens = query.trim().split(/\s+/).filter(Boolean)
454
+ if (tokens.length === 0) return false
455
+ const lower = text.toLowerCase()
456
+ let searchPos = 0
457
+ for (const token of tokens) {
458
+ const index = lower.indexOf(token.toLowerCase(), searchPos)
459
+ if (index === -1) return false
460
+ searchPos = index + token.length
461
+ }
462
+ return true
463
+ }
464
+
389
465
  function parseApplyPatchFiles(metadata: unknown) {
390
466
  const files = recordValue(metadata)?.files
391
467
  if (!Array.isArray(files)) return []
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.17",
4
+ "version": "0.1.19",
5
5
  "description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/search.ts CHANGED
@@ -95,6 +95,7 @@ let _db: Database | undefined
95
95
  let _dbPath: string | undefined
96
96
  let _indexDb: Database | undefined
97
97
  let _indexDbPath: string | undefined
98
+ const backgroundIndexRebuilds = new Set<string>()
98
99
 
99
100
  function getDb(dbPath?: string): Database {
100
101
  const resolved = dbPath ?? resolveDatabasePath()
@@ -148,8 +149,13 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
148
149
  }
149
150
 
150
151
  export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
151
- const db = getDb(options?.dbPath)
152
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset, options?.role).flatMap(
152
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
153
+ const db = getDb(dbPath)
154
+ const limit = options?.limit ?? 40
155
+ const indexed = dbPath === ":memory:" ? undefined : indexedRecentRows(db, dbPath, limit, options?.directory, options?.offset, options?.role)
156
+ if (indexed) return indexed.flatMap((row) => rowToSearchResult(row, "") ?? [])
157
+ debug.log("query:recent:source-fallback", { limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
158
+ return visibleTextRows(db, limit, undefined, options?.directory, options?.offset, options?.role).flatMap(
153
159
  (row) => rowToSearchResult(row, "") ?? [],
154
160
  )
155
161
  }
@@ -231,7 +237,11 @@ export function loadConversationAround(result: SearchResult, options?: { before?
231
237
  fetchBefore,
232
238
  fetchAfter,
233
239
  beforeRows: beforeRows.length,
240
+ validBefore: validBefore.length,
241
+ invalidBefore: previewRowBreakdown(beforeRows, validBefore),
234
242
  afterRows: afterRows.length,
243
+ validAfter: validAfter.length,
244
+ invalidAfter: previewRowBreakdown(afterRows, validAfter),
235
245
  parts: parts.length,
236
246
  hasMoreBefore: page.hasMoreBefore,
237
247
  hasMoreAfter: page.hasMoreAfter,
@@ -262,6 +272,7 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
262
272
  `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
263
273
  const valid = rows.filter(isPreviewRow)
264
274
  const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
275
+ const parsedIds = new Set(parts.map((part) => part.id))
265
276
  const page = { parts, hasMoreBefore: valid.length > limit }
266
277
  debug.log("preview:window", {
267
278
  item: result.id,
@@ -270,7 +281,10 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
270
281
  cursor,
271
282
  limit,
272
283
  rows: rows.length,
284
+ valid: valid.length,
285
+ invalid: previewRowBreakdown(rows, valid),
273
286
  parts: parts.length,
287
+ unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
274
288
  hasMoreBefore: page.hasMoreBefore,
275
289
  first: parts[0]?.id,
276
290
  last: parts.at(-1)?.id,
@@ -298,6 +312,7 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
298
312
  `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
299
313
  const valid = rows.filter(isPreviewRow)
300
314
  const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
315
+ const parsedIds = new Set(parts.map((part) => part.id))
301
316
  const page = { parts, hasMoreAfter: valid.length > limit }
302
317
  debug.log("preview:window", {
303
318
  item: result.id,
@@ -306,7 +321,10 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
306
321
  cursor,
307
322
  limit,
308
323
  rows: rows.length,
324
+ valid: valid.length,
325
+ invalid: previewRowBreakdown(rows, valid),
309
326
  parts: parts.length,
327
+ unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
310
328
  hasMoreAfter: page.hasMoreAfter,
311
329
  first: parts[0]?.id,
312
330
  last: parts.at(-1)?.id,
@@ -501,7 +519,7 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
501
519
  role: row.role,
502
520
  type: row.type,
503
521
  timeCreated: row.time_created,
504
- text: extractToolIndexText(data).trim(),
522
+ text: target ? extractToolIndexText(data).trim() : "",
505
523
  tool: typeof data.tool === "string" ? data.tool : "tool",
506
524
  state: parseToolState(data.state),
507
525
  target,
@@ -526,6 +544,21 @@ function isPreviewRow(row: ConversationRow) {
526
544
  (row.type === "text" || row.type === "reasoning" || row.type === "tool")
527
545
  }
528
546
 
547
+ function previewRowBreakdown(rows: ConversationRow[], validRows: ConversationRow[]) {
548
+ const valid = new Set(validRows.map((row) => row.id))
549
+ const invalidRows = rows.filter((row) => !valid.has(row.id))
550
+ if (invalidRows.length === 0) return undefined
551
+ const byRole = countBy(invalidRows, (row) => String(row.role ?? "unknown"))
552
+ const byType = countBy(invalidRows, (row) => String(row.type ?? "unknown"))
553
+ return { count: invalidRows.length, byRole, byType }
554
+ }
555
+
556
+ function countBy<T>(items: T[], key: (item: T) => string) {
557
+ const counts: Record<string, number> = {}
558
+ for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1
559
+ return counts
560
+ }
561
+
529
562
  function parsePartData(data: string) {
530
563
  try {
531
564
  const value = JSON.parse(data) as unknown
@@ -565,6 +598,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
565
598
  if (!match) return []
566
599
  try {
567
600
  const index = ensureSearchIndex(db, dbPath)
601
+ if (!index) return []
568
602
  const conditions = ["document_fts MATCH ?"]
569
603
  const params: (string | number)[] = [match]
570
604
  if (role) {
@@ -595,6 +629,46 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
595
629
  }
596
630
  }
597
631
 
632
+ function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
633
+ try {
634
+ const index = ensureSearchIndex(db, dbPath, { rebuild: false })
635
+ if (!index) {
636
+ scheduleBackgroundIndexRebuild(dbPath)
637
+ return
638
+ }
639
+
640
+ const conditions: string[] = ["part_type = 'text'"]
641
+ const params: (string | number)[] = []
642
+ if (role) {
643
+ conditions.push("role = ?")
644
+ params.push(role)
645
+ }
646
+ if (directory) {
647
+ conditions.push("directory = ?")
648
+ params.push(directory)
649
+ }
650
+ params.push(limit)
651
+ if (offset) params.push(offset)
652
+
653
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
654
+ const offsetClause = offset ? "OFFSET ?" : ""
655
+ debug.time("query:recent:index:exec")
656
+ const rows = index.query<Row, (string | number)[]>(`
657
+ SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
658
+ CAST(time_created AS INTEGER) AS time_created, text
659
+ FROM document_index
660
+ ${where}
661
+ ORDER BY CAST(time_created AS INTEGER) DESC
662
+ LIMIT ? ${offsetClause}
663
+ `).all(...params as any[])
664
+ debug.timeEnd("query:recent:index:exec")
665
+ return rows
666
+ } catch (err) {
667
+ debug.log("recent:index:fallback", err instanceof Error ? err.message : String(err))
668
+ return
669
+ }
670
+ }
671
+
598
672
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
599
673
  const offsetClause = offset ? "OFFSET ?" : ""
600
674
  const conditions: string[] = [
@@ -637,7 +711,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
637
711
  return rows
638
712
  }
639
713
 
640
- function ensureSearchIndex(source: Database, sourcePath: string) {
714
+ function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean }) {
641
715
  const indexPath = searchIndexPath(sourcePath)
642
716
  if (!_indexDb || _indexDbPath !== indexPath) {
643
717
  _indexDb?.close()
@@ -652,12 +726,32 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
652
726
  const currentPath = getMeta(_indexDb, "source_path")
653
727
  const currentIndexVersion = getMeta(_indexDb, "index_version")
654
728
  if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
729
+ if (options?.rebuild === false) return
655
730
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
656
731
  }
657
732
  return _indexDb
658
733
  }
659
734
 
660
- const SEARCH_INDEX_VERSION = "4"
735
+ function scheduleBackgroundIndexRebuild(dbPath: string) {
736
+ if (backgroundIndexRebuilds.has(dbPath)) return
737
+ backgroundIndexRebuilds.add(dbPath)
738
+ debug.log("fts:rebuild:background-scheduled", { dbPath })
739
+ const timer = setTimeout(() => {
740
+ debug.time("fts:rebuild:background")
741
+ try {
742
+ const db = getDb(dbPath)
743
+ ensureSearchIndex(db, dbPath)
744
+ } catch (err) {
745
+ debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
746
+ } finally {
747
+ backgroundIndexRebuilds.delete(dbPath)
748
+ debug.timeEnd("fts:rebuild:background")
749
+ }
750
+ }, 250)
751
+ ;(timer as { unref?: () => void }).unref?.()
752
+ }
753
+
754
+ const SEARCH_INDEX_VERSION = "6"
661
755
 
662
756
  function searchIndexPath(sourcePath: string) {
663
757
  const parsed = path.parse(sourcePath)
@@ -683,6 +777,24 @@ function migrateSearchIndex(db: Database) {
683
777
  text,
684
778
  tokenize='unicode61'
685
779
  );
780
+ CREATE TABLE IF NOT EXISTS document_index(
781
+ id TEXT PRIMARY KEY,
782
+ message_id TEXT NOT NULL,
783
+ session_id TEXT NOT NULL,
784
+ session_title TEXT NOT NULL,
785
+ directory TEXT NOT NULL,
786
+ role TEXT NOT NULL,
787
+ part_type TEXT NOT NULL,
788
+ tool TEXT,
789
+ time_created INTEGER NOT NULL,
790
+ text TEXT NOT NULL
791
+ );
792
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
793
+ ON document_index(directory, role, time_created DESC);
794
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
795
+ ON document_index(directory, role, part_type, time_created DESC);
796
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
797
+ ON document_index(time_created DESC);
686
798
  `)
687
799
 
688
800
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
@@ -704,6 +816,33 @@ function migrateSearchIndex(db: Database) {
704
816
  );
705
817
  `)
706
818
  }
819
+
820
+ const indexColumns = db.query<{ name: string }, []>("PRAGMA table_info(document_index)").all().map((column) => column.name)
821
+ if (!["part_type", "tool", "time_created", "text"].every((name) => indexColumns.includes(name))) {
822
+ db.exec("DROP TABLE IF EXISTS document_index")
823
+ db.exec(`
824
+ CREATE TABLE document_index(
825
+ id TEXT PRIMARY KEY,
826
+ message_id TEXT NOT NULL,
827
+ session_id TEXT NOT NULL,
828
+ session_title TEXT NOT NULL,
829
+ directory TEXT NOT NULL,
830
+ role TEXT NOT NULL,
831
+ part_type TEXT NOT NULL,
832
+ tool TEXT,
833
+ time_created INTEGER NOT NULL,
834
+ text TEXT NOT NULL
835
+ );
836
+ `)
837
+ }
838
+ db.exec(`
839
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
840
+ ON document_index(directory, role, time_created DESC);
841
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
842
+ ON document_index(directory, role, part_type, time_created DESC);
843
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
844
+ ON document_index(time_created DESC);
845
+ `)
707
846
  }
708
847
 
709
848
  function sourceState(db: Database, sourcePath: string) {
@@ -738,9 +877,14 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
738
877
  INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
739
878
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
740
879
  `)
880
+ const insertIndex = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
881
+ INSERT INTO document_index(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
882
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
883
+ `)
741
884
  index.exec("BEGIN IMMEDIATE")
742
885
  try {
743
886
  index.exec("DELETE FROM document_fts")
887
+ index.exec("DELETE FROM document_index")
744
888
  for (const row of rows.flatMap(indexSourceRowToRows)) {
745
889
  insert.run(
746
890
  row.id,
@@ -754,6 +898,18 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
754
898
  row.time_created,
755
899
  row.text,
756
900
  )
901
+ insertIndex.run(
902
+ row.id,
903
+ row.message_id,
904
+ row.session_id,
905
+ row.session_title ?? "Untitled session",
906
+ row.directory,
907
+ row.role,
908
+ row.part_type ?? "text",
909
+ row.tool ?? null,
910
+ row.time_created,
911
+ row.text,
912
+ )
757
913
  }
758
914
  setMeta(index, "source_path", sourcePath)
759
915
  setMeta(index, "source_data_version", String(state.dataVersion))
package/telescope.tsx CHANGED
@@ -20,7 +20,7 @@ import { debug } from "./ui/debug.ts"
20
20
  import { syntaxStyle } from "./ui/format.ts"
21
21
  import type { TelescopeConfig } from "./ui/config.ts"
22
22
  import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.ts"
23
- import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
23
+ import { findRenderableByID, jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
24
24
 
25
25
  export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
26
26
  type OwnerFilter = "all" | SearchRole
@@ -46,11 +46,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
46
46
  const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
47
47
  const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
48
  const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
49
+ const [previewWindow, setPreviewWindow] = createSignal({ start: 0, end: 0 })
50
+ const [previewHeightVersion, setPreviewHeightVersion] = createSignal(0)
49
51
  const MIN_SEARCH_BATCH_SIZE = 25
50
52
  const MIN_RECENT_BATCH_SIZE = 15
51
53
  const RESULT_OVERSCAN_MULTIPLIER = 2
52
54
  const RESULT_BATCH_VIEWPORTS = 4
53
- const RESULT_PREFETCH_VIEWPORTS = 10
55
+ const RESULT_PREFETCH_VIEWPORTS = 3
54
56
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
55
57
  const INITIAL_PREVIEW_BEFORE = 20
56
58
  const INITIAL_PREVIEW_AFTER = 30
@@ -87,12 +89,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
87
89
  const directory = props.api.state.path.directory
88
90
  let advanceSelectionAfterLoad = false
89
91
  let advanceSelectionBeforeLoad = false
92
+ let resultNavigationStarted = false
90
93
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
91
94
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
95
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
96
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
97
  let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
98
  let pendingPreviewAfterVisible = false
99
+ const previewMeasuredHeights = new Map<string, number>()
96
100
  const cancelPreviewPrefetch = () => {
97
101
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
102
  if (previewAfterTimer) clearTimeout(previewAfterTimer)
@@ -177,6 +181,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
177
181
  resultPreviousTimer = undefined
178
182
  advanceSelectionAfterLoad = false
179
183
  advanceSelectionBeforeLoad = false
184
+ resultNavigationStarted = false
180
185
  setLoadingMore(false)
181
186
  setLoadingPreviousResults(false)
182
187
  setPrefetchingResults(false)
@@ -187,9 +192,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
187
192
  setLoading(true)
188
193
  const limit = recentBatchSize()
189
194
  const timer = setTimeout(() => {
195
+ debug.log("bootstrap:recent:start", { limit, directory: dir, role })
190
196
  debug.time("query:recent")
191
197
  try {
192
198
  const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
199
+ debug.log("bootstrap:recent:done", { rows: batch.length, limit })
193
200
  solidBatch(() => {
194
201
  setResults(batch)
195
202
  setResultBaseOffset(0)
@@ -199,6 +206,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
199
206
  setSelected(0)
200
207
  })
201
208
  } catch (err) {
209
+ debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
202
210
  solidBatch(() => {
203
211
  setResults([])
204
212
  setResultBaseOffset(0)
@@ -220,7 +228,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
220
228
  const timer = setTimeout(() => {
221
229
  debug.time("query:search")
222
230
  try {
231
+ debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
223
232
  const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
233
+ debug.log("bootstrap:search:done", { rows: batch.length, limit })
224
234
  solidBatch(() => {
225
235
  setResults(batch)
226
236
  setResultBaseOffset(0)
@@ -370,6 +380,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
370
380
 
371
381
  const move = (delta: number) => {
372
382
  if (results().length === 0) return
383
+ resultNavigationStarted = true
373
384
  setSelected((index) => {
374
385
  const base = resultBaseOffset()
375
386
  const cachedEnd = base + results().length
@@ -409,6 +420,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
409
420
  const state = resultPrefetchState()
410
421
  const blockedBy = !hasMore()
411
422
  ? "no-more"
423
+ : !resultNavigationStarted
424
+ ? "waiting-for-navigation"
412
425
  : loadingMore()
413
426
  ? "loading-more"
414
427
  : loadingPreviousResults()
@@ -431,19 +444,161 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
431
444
  onCleanup(() => clearTimeout(timer))
432
445
  })
433
446
 
434
- const previewContentHeight = () => {
435
- const children = previewScroll?.getChildren()
447
+ const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
448
+ if (part.type === "tool") {
449
+ if (!part.target) return 2
450
+ if (part.tool === "write") return 40
451
+ if (part.tool === "edit" || part.tool === "apply_patch") return 35
452
+ return 2
453
+ }
454
+ if (part.type === "reasoning") return Math.min(24, Math.max(2, Math.ceil(part.text.length / 120)))
455
+ return Math.min(90, Math.max(3, Math.ceil(part.text.length / 90)))
456
+ }
457
+
458
+ const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
459
+
460
+ const previewVirtualLayout = createMemo(() => {
461
+ previewHeightVersion()
462
+ let offset = 0
463
+ return previewParts().map((part) => {
464
+ const height = previewPartHeight(part)
465
+ const row = { part, top: offset, bottom: offset + height, height }
466
+ offset += height
467
+ return row
468
+ })
469
+ })
470
+
471
+ const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
472
+
473
+ const updatePreviewWindow = () => {
474
+ const scroll = previewScroll
475
+ const layout = previewVirtualLayout()
476
+ if (layout.length === 0) {
477
+ setPreviewWindow({ start: 0, end: 0 })
478
+ return
479
+ }
480
+ if (!scroll) {
481
+ const next = { start: 0, end: Math.min(layout.length, 20) }
482
+ const current = previewWindow()
483
+ if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
484
+ return
485
+ }
486
+
487
+ const overscan = Math.max(scroll.height * 2, 40)
488
+ const from = Math.max(0, scroll.scrollTop - overscan)
489
+ const to = scroll.scrollTop + scroll.height + overscan
490
+ const foundStart = layout.findIndex((row) => row.bottom >= from)
491
+ const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
492
+ const foundEnd = layout.findIndex((row) => row.top > to)
493
+ const end = foundEnd === -1 ? layout.length : foundEnd
494
+ const next = { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
495
+ const current = previewWindow()
496
+ if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
497
+ }
498
+
499
+ const previewWindowParts = createMemo(() => {
500
+ const { start, end } = previewWindow()
501
+ return previewParts().slice(start, end)
502
+ })
503
+
504
+ const previewTopSpacerHeight = createMemo(() => {
505
+ const { start } = previewWindow()
506
+ return previewVirtualLayout()[start]?.top ?? 0
507
+ })
508
+
509
+ const previewBottomSpacerHeight = createMemo(() => {
510
+ const { end } = previewWindow()
511
+ const layout = previewVirtualLayout()
512
+ const total = layout.at(-1)?.bottom ?? 0
513
+ const renderedBottom = end > 0 ? layout[end - 1]?.bottom ?? 0 : 0
514
+ return Math.max(0, total - renderedBottom)
515
+ })
516
+
517
+ const previewScrollState = () => {
518
+ const scroll = previewScroll
519
+ const children = scroll?.getChildren()
436
520
  const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
437
- return lastChild ? lastChild.y + lastChild.height : 0
521
+ return {
522
+ y: scroll?.y,
523
+ height: scroll?.height,
524
+ scrollTop: scroll?.scrollTop,
525
+ scrollHeight: scroll?.scrollHeight,
526
+ childContentHeight: lastChild ? lastChild.y + lastChild.height : 0,
527
+ children: children?.length ?? 0,
528
+ hasMoreBefore: hasMorePreviewBefore(),
529
+ hasMoreAfter: hasMorePreviewAfter(),
530
+ loadingMore: loadingPreviewMore(),
531
+ prefetchingBefore: prefetchingPreviewBefore(),
532
+ prefetchingAfter: prefetchingPreviewAfter(),
533
+ timerBefore: Boolean(previewBeforeTimer),
534
+ timerAfter: Boolean(previewAfterTimer),
535
+ }
438
536
  }
439
537
 
538
+ const measurePreviewWindow = () => {
539
+ const scroll = previewScroll
540
+ if (!scroll) return
541
+
542
+ let changed = false
543
+ for (const part of previewWindowParts()) {
544
+ const node = findRenderableByID(scroll, `preview-part-${part.id}`)
545
+ const height = node?.height
546
+ if (!height || height <= 0) continue
547
+ if (previewMeasuredHeights.get(part.id) !== height) {
548
+ previewMeasuredHeights.set(part.id, height)
549
+ changed = true
550
+ }
551
+ }
552
+
553
+ if (changed) {
554
+ debug.log("preview:measure", {
555
+ measured: previewWindowParts().length,
556
+ totalMeasured: previewMeasuredHeights.size,
557
+ window: previewWindow(),
558
+ })
559
+ setPreviewHeightVersion((value) => value + 1)
560
+ setTimeout(updatePreviewWindow, 1)
561
+ }
562
+ }
563
+
564
+ createEffect(() => {
565
+ previewParts()
566
+ previewHeightVersion()
567
+ const timer = setTimeout(updatePreviewWindow, 1)
568
+ onCleanup(() => clearTimeout(timer))
569
+ })
570
+
571
+ createEffect(() => {
572
+ previewWindowParts()
573
+ const timer = setTimeout(measurePreviewWindow, 1)
574
+ onCleanup(() => clearTimeout(timer))
575
+ })
576
+
440
577
  const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
441
578
  const item = selectedResult()
442
579
  const first = previewParts()[0]
443
- if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
580
+ if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) {
581
+ debug.log("preview:load-before:skip", {
582
+ reason: !item ? "no-item" : !first ? "no-first-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
583
+ previousContentHeight,
584
+ preserveScroll,
585
+ visibleLoad,
586
+ state: previewScrollState(),
587
+ })
588
+ return
589
+ }
590
+ const beforeState = previewScrollState()
444
591
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
445
592
  debug.time("preview:load-before")
446
593
  try {
594
+ debug.log("preview:load-before:start", {
595
+ item: item.id,
596
+ cursor: { id: first.id, timeCreated: first.timeCreated },
597
+ previousContentHeight,
598
+ preserveScroll,
599
+ visibleLoad,
600
+ state: beforeState,
601
+ })
447
602
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
448
603
  debug.log("preview:load-before", {
449
604
  item: item.id,
@@ -458,8 +613,27 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
458
613
  setPreviewParts((prev) => [...page.parts, ...prev])
459
614
  if (preserveScroll) {
460
615
  setTimeout(() => {
616
+ const beforeAdjust = previewScrollState()
461
617
  const delta = previewContentHeight() - previousContentHeight
462
618
  if (delta > 0) previewScroll?.scrollBy(delta)
619
+ updatePreviewWindow()
620
+ debug.log("preview:load-before:adjust", {
621
+ delta,
622
+ previousContentHeight,
623
+ newContentHeight: previewContentHeight(),
624
+ scrolled: delta > 0,
625
+ beforeAdjust,
626
+ afterAdjust: previewScrollState(),
627
+ })
628
+ }, 1)
629
+ } else {
630
+ setTimeout(() => {
631
+ updatePreviewWindow()
632
+ debug.log("preview:load-before:no-adjust", {
633
+ previousContentHeight,
634
+ newContentHeight: previewContentHeight(),
635
+ state: previewScrollState(),
636
+ })
463
637
  }, 1)
464
638
  }
465
639
  }
@@ -475,7 +649,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
475
649
  const loadPreviewAfter = (visibleLoad = false) => {
476
650
  const item = selectedResult()
477
651
  const last = previewParts().at(-1)
478
- if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
652
+ if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) {
653
+ debug.log("preview:load-after:skip", {
654
+ reason: !item ? "no-item" : !last ? "no-last-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
655
+ visibleLoad,
656
+ state: previewScrollState(),
657
+ })
658
+ return
659
+ }
660
+ debug.log("preview:load-after:start", {
661
+ item: item.id,
662
+ cursor: { id: last.id, timeCreated: last.timeCreated },
663
+ visibleLoad,
664
+ state: previewScrollState(),
665
+ })
479
666
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
480
667
  debug.time("preview:load-after")
481
668
  try {
@@ -488,7 +675,10 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
488
675
  first: page.parts[0]?.id,
489
676
  last: page.parts.at(-1)?.id,
490
677
  })
491
- if (page.parts.length > 0) setPreviewParts((prev) => [...prev, ...page.parts])
678
+ if (page.parts.length > 0) {
679
+ setPreviewParts((prev) => [...prev, ...page.parts])
680
+ setTimeout(updatePreviewWindow, 1)
681
+ }
492
682
  setHasMorePreviewAfter(page.hasMoreAfter)
493
683
  } catch (err) {
494
684
  debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
@@ -499,16 +689,40 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
499
689
  }
500
690
 
501
691
  const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
502
- if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
692
+ if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) {
693
+ debug.log("preview:prefetch-before:skip", {
694
+ reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
695
+ previousContentHeight,
696
+ preserveScroll,
697
+ visibleLoad,
698
+ state: previewScrollState(),
699
+ })
700
+ return
701
+ }
503
702
  if (previewBeforeTimer) {
504
703
  if (pendingPreviewBefore) {
704
+ const previousPending = { ...pendingPreviewBefore }
505
705
  pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
506
706
  pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
707
+ debug.log("preview:prefetch-before:merge", {
708
+ previousPending,
709
+ nextPending: pendingPreviewBefore,
710
+ requested: { previousContentHeight, preserveScroll, visibleLoad },
711
+ state: previewScrollState(),
712
+ })
713
+ } else {
714
+ debug.log("preview:prefetch-before:skip", {
715
+ reason: "timer-already-set",
716
+ previousContentHeight,
717
+ preserveScroll,
718
+ visibleLoad,
719
+ state: previewScrollState(),
720
+ })
507
721
  }
508
722
  return
509
723
  }
510
724
  pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
- debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
725
+ debug.log("preview:prefetch-before-scheduled", { previousContentHeight, preserveScroll, visibleLoad, state: previewScrollState() })
512
726
  previewBeforeTimer = setTimeout(() => {
513
727
  const pending = pendingPreviewBefore
514
728
  previewBeforeTimer = undefined
@@ -518,10 +732,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
518
732
  }
519
733
 
520
734
  const schedulePreviewAfter = (visibleLoad = false) => {
521
- if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
735
+ if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) {
736
+ debug.log("preview:prefetch-after:skip", {
737
+ reason: !hasMorePreviewAfter() ? "no-more-after" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
738
+ visibleLoad,
739
+ state: previewScrollState(),
740
+ })
741
+ return
742
+ }
522
743
  pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
523
- if (previewAfterTimer) return
524
- debug.log("preview:prefetch-after-scheduled", { visibleLoad })
744
+ if (previewAfterTimer) {
745
+ debug.log("preview:prefetch-after:skip", { reason: "timer-already-set", visibleLoad, pendingVisibleLoad: pendingPreviewAfterVisible, state: previewScrollState() })
746
+ return
747
+ }
748
+ debug.log("preview:prefetch-after-scheduled", { visibleLoad, state: previewScrollState() })
525
749
  previewAfterTimer = setTimeout(() => {
526
750
  const pendingVisibleLoad = pendingPreviewAfterVisible
527
751
  previewAfterTimer = undefined
@@ -530,12 +754,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
530
754
  }, 1)
531
755
  }
532
756
 
757
+ const scrollPreviewToEstimatedTarget = (item: SearchResult) => {
758
+ const scroll = previewScroll
759
+ if (!scroll) return
760
+ const targetID = messageTargetID(item)
761
+ const row = previewVirtualLayout().find((entry) => entry.part.id === item.id)
762
+ if (!row) {
763
+ scrollPreviewToTarget(scroll, targetID)
764
+ return
765
+ }
766
+
767
+ const top = Math.max(0, row.top - Math.max(1, Math.floor(scroll.height / 3)))
768
+ debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
769
+ scroll.scrollTo(top)
770
+ updatePreviewWindow()
771
+ setTimeout(() => scrollPreviewToTarget(scroll, targetID), 1)
772
+ }
773
+
533
774
  let lastPreviewItemId = ""
534
775
  createEffect(() => {
535
776
  const item = selectedResult()
536
777
  if (!item) {
537
778
  cancelPreviewPrefetch()
538
779
  setPreviewParts([])
780
+ previewMeasuredHeights.clear()
781
+ setPreviewHeightVersion((value) => value + 1)
782
+ setPreviewWindow({ start: 0, end: 0 })
539
783
  setHasMorePreviewBefore(false)
540
784
  setHasMorePreviewAfter(false)
541
785
  return
@@ -543,6 +787,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
543
787
  if (item.id === lastPreviewItemId) return
544
788
  lastPreviewItemId = item.id
545
789
  cancelPreviewPrefetch()
790
+ previewMeasuredHeights.clear()
791
+ setPreviewHeightVersion((value) => value + 1)
792
+ setPreviewWindow({ start: 0, end: 0 })
546
793
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
547
794
  const db = dbPath()
548
795
  debug.time("preview:load")
@@ -560,6 +807,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
560
807
  setPreviewParts(page.parts)
561
808
  setHasMorePreviewBefore(page.hasMoreBefore)
562
809
  setHasMorePreviewAfter(page.hasMoreAfter)
810
+ setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
563
811
  } catch {}
564
812
  debug.timeEnd("nav:total")
565
813
  debug.timeEnd("preview:load")
@@ -573,18 +821,19 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
573
821
  const scroll = previewScroll
574
822
  const children = scroll?.getChildren()
575
823
  if (!scroll || !children || children.length === 0) return
576
- const lastChild = children[children.length - 1] as { y: number; height: number }
577
- const totalContentHeight = lastChild.y + lastChild.height
578
- const atTop = scroll.y <= 0
824
+ const totalContentHeight = previewContentHeight()
825
+ const atTop = scroll.scrollTop <= 0
579
826
  const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
580
- const nearTop = scroll.y <= prefetchDistance
581
- const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
582
- const nearBottom = scroll.y + scroll.height >= totalContentHeight - prefetchDistance
827
+ const nearTop = scroll.scrollTop <= prefetchDistance
828
+ const atBottom = scroll.scrollTop + scroll.height >= totalContentHeight - 1
829
+ const nearBottom = scroll.scrollTop + scroll.height >= totalContentHeight - prefetchDistance
583
830
  if (nearTop || nearBottom) {
584
831
  debug.log("preview:scroll-edge", {
585
832
  y: scroll.y,
833
+ scrollTop: scroll.scrollTop,
586
834
  height: scroll.height,
587
835
  contentHeight: totalContentHeight,
836
+ childContentHeight: previewScrollState().childContentHeight,
588
837
  prefetchDistance,
589
838
  atTop,
590
839
  nearTop,
@@ -597,7 +846,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
597
846
  children: children.length,
598
847
  })
599
848
  }
600
- if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, false, true)
849
+ if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, true, false)
601
850
  if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
602
851
  }, 400)
603
852
  onCleanup(() => clearInterval(interval))
@@ -606,11 +855,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
606
855
  let scrolledItem = ""
607
856
  createEffect(() => {
608
857
  const item = selectedResult()
609
- previewParts()
858
+ previewVirtualLayout()
610
859
  if (!item) return
611
860
  if (item.id === scrolledItem) return
612
861
  scrolledItem = item.id
613
- const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
862
+ const timer = setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
614
863
  onCleanup(() => clearTimeout(timer))
615
864
  })
616
865
 
@@ -625,7 +874,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
625
874
 
626
875
  const scrollPreview = (direction: 1 | -1, evt: ParsedKey) => {
627
876
  prevent(evt)
628
- previewScroll?.scrollBy(direction * previewScrollAmount(previewScroll))
877
+ const scroll = previewScroll
878
+ if (!scroll) {
879
+ debug.log("preview:scroll-key:skip", { reason: "no-scroll", direction })
880
+ return
881
+ }
882
+
883
+ const beforeState = previewScrollState()
884
+ debug.log("preview:scroll-key", { direction, state: beforeState })
885
+
886
+ if (direction < 0 && scroll.scrollTop <= 0 && hasMorePreviewBefore()) {
887
+ debug.log("preview:scroll-key:load-before", { direction, state: beforeState })
888
+ schedulePreviewBefore(previewContentHeight(), true, true)
889
+ return
890
+ }
891
+
892
+ const totalContentHeight = previewContentHeight()
893
+ if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
894
+ debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
895
+ schedulePreviewAfter(true)
896
+ return
897
+ }
898
+
899
+ const amount = direction * previewScrollAmount(scroll)
900
+ scroll.scrollBy(amount)
901
+ updatePreviewWindow()
902
+ debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
629
903
  }
630
904
 
631
905
  const focusInput = () => {
@@ -793,7 +1067,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
793
1067
  <PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
794
1068
  <scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
795
1069
  <Show when={selectedResult()} fallback={<text fg={theme().textMuted}>Select a hit to preview the exact matched message.</text>}>
796
- {(item) => <ConversationPreview item={item()} parts={previewParts()} syntax={syntax()} theme={theme()} />}
1070
+ {(item) => (
1071
+ <box flexDirection="column" flexShrink={0}>
1072
+ <box height={previewTopSpacerHeight()} flexShrink={0} />
1073
+ <ConversationPreview item={item()} parts={previewWindowParts()} syntax={syntax()} theme={theme()} />
1074
+ <box height={previewBottomSpacerHeight()} flexShrink={0} />
1075
+ </box>
1076
+ )}
797
1077
  </Show>
798
1078
  </scrollbox>
799
1079
  </box>
@@ -1,5 +1,6 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core"
2
2
  import type { SearchResult } from "../search.ts"
3
+ import { debug } from "./debug.ts"
3
4
 
4
5
  export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
5
6
  return Math.max(1, Math.floor((scroll?.height || 10) / 8))
@@ -13,10 +14,35 @@ export function messageTargetID(item: SearchResult) {
13
14
  }
14
15
 
15
16
  export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, targetID: string) {
16
- if (!scroll) return
17
+ if (!scroll) {
18
+ debug.log("preview:target-scroll:skip", { reason: "no-scroll", targetID })
19
+ return
20
+ }
17
21
  const target = findRenderableByID(scroll, targetID)
18
- if (!target) return
19
- scroll.scrollBy(target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3)))
22
+ if (!target) {
23
+ debug.log("preview:target-scroll:skip", {
24
+ reason: "target-not-found",
25
+ targetID,
26
+ y: scroll.y,
27
+ scrollTop: scroll.scrollTop,
28
+ scrollHeight: scroll.scrollHeight,
29
+ height: scroll.height,
30
+ children: scroll.getChildren().length,
31
+ })
32
+ return
33
+ }
34
+ const delta = target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3))
35
+ debug.log("preview:target-scroll", {
36
+ targetID,
37
+ targetY: target.y,
38
+ scrollY: scroll.y,
39
+ scrollTop: scroll.scrollTop,
40
+ scrollHeight: scroll.height,
41
+ contentHeight: scroll.scrollHeight,
42
+ delta,
43
+ })
44
+ scroll.scrollBy(delta)
45
+ debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop })
20
46
  }
21
47
 
22
48
  export function jumpToRenderedTarget(root: unknown, targetID: string) {
@@ -33,9 +59,10 @@ export function jumpToRenderedTarget(root: unknown, targetID: string) {
33
59
  setTimeout(tick, 50)
34
60
  }
35
61
 
36
- type RenderNode = {
62
+ export type RenderNode = {
37
63
  id?: string
38
64
  y: number
65
+ height?: number
39
66
  getChildren(): unknown[]
40
67
  }
41
68
 
@@ -68,7 +95,7 @@ function isScrollNode(value: RenderNode): value is ScrollNode {
68
95
  return "scrollBy" in value && typeof value.scrollBy === "function"
69
96
  }
70
97
 
71
- function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
98
+ export function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
72
99
  if (!isRenderNode(node)) return
73
100
  if (node.id === targetID) return node
74
101
  for (const child of node.getChildren()) {