@bojackduy/opencode-telescope 0.1.16 → 0.1.18

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.
@@ -59,7 +59,7 @@ export const ConversationPreview = (props: { item: SearchResult; parts: Conversa
59
59
  )
60
60
 
61
61
  const PreviewConversationPart = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
62
- if (props.part.type === "tool") return <PreviewToolPart part={props.part} theme={props.theme} />
62
+ if (props.part.type === "tool") return <PreviewToolPart part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
63
63
  if (props.part.type === "reasoning") return <PreviewReasoningPart part={props.part} syntax={props.syntax} theme={props.theme} />
64
64
  if (props.part.role === "assistant") return <PreviewAssistantPart part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
65
65
  return <PreviewUserPart part={props.part} item={props.item} theme={props.theme} />
@@ -129,7 +129,7 @@ const PreviewReasoningPart = (props: { part: ConversationPreviewPart; syntax: Sy
129
129
  )
130
130
  }
131
131
 
132
- const PreviewToolPart = (props: { part: ConversationPreviewPart; theme: TuiThemeCurrent }) => {
132
+ const PreviewToolPart = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
133
133
  const status = createMemo(() => props.part.state?.status ?? "pending")
134
134
  const failed = createMemo(() => status() === "error")
135
135
  const color = createMemo(() => {
@@ -137,27 +137,169 @@ const PreviewToolPart = (props: { part: ConversationPreviewPart; theme: TuiTheme
137
137
  if (status() === "completed") return props.theme.textMuted
138
138
  return props.theme.text
139
139
  })
140
+ const codeTool = createMemo(() => props.part.tool === "apply_patch" || props.part.tool === "edit" || props.part.tool === "write")
140
141
  return (
141
- <box id={`tool-inline-${props.part.messageID}-${props.part.id}`} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
142
+ <box id={`tool-${props.part.messageID}-${props.part.id}`} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
142
143
  <Show when={props.part.target}>
143
- <TargetMarker part={props.part} role="tool" time={props.part.timeCreated} theme={props.theme} />
144
+ <TargetMarker part={props.part} item={props.item} role={props.part.tool ?? "tool"} time={props.part.timeCreated} theme={props.theme} />
145
+ </Show>
146
+ <Show when={codeTool() && props.part.target} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
147
+ <CodeToolPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
144
148
  </Show>
145
- <text fg={color()} wrapMode="none" overflow="hidden">
146
- <span style={{ fg: failed() ? props.theme.error : props.theme.textMuted }}>{toolIcon(props.part.tool)} </span>
147
- <span>{toolLabel(props.part.tool)}</span>
148
- <span style={{ fg: props.theme.textMuted }}> {toolInputSummary(props.part.state?.input)}</span>
149
- <span style={{ fg: props.theme.textMuted }}> · {status()}</span>
150
- </text>
151
149
  <Show when={props.part.state?.error}>
152
150
  {(error) => <text fg={props.theme.error}>{error()}</text>}
153
151
  </Show>
154
- <Show when={failed() ? props.part.state?.output : undefined}>
155
- {(output) => <text fg={props.theme.textMuted}>{truncate(output().trim(), 300)}</text>}
156
- </Show>
157
152
  </box>
158
153
  )
159
154
  }
160
155
 
156
+ const CompactToolRow = (props: { part: ConversationPreviewPart; color: any; failed: boolean; theme: TuiThemeCurrent }) => (
157
+ <>
158
+ <text fg={props.color} wrapMode="none" overflow="hidden">
159
+ <span style={{ fg: props.failed ? props.theme.error : props.theme.textMuted }}>{toolIcon(props.part.tool)} </span>
160
+ <span>{toolLabel(props.part.tool)}</span>
161
+ <span style={{ fg: props.theme.textMuted }}> {toolInputSummary(props.part.state?.input)}</span>
162
+ <span style={{ fg: props.theme.textMuted }}> · {props.part.state?.status ?? "pending"}</span>
163
+ </text>
164
+ <Show when={props.failed ? props.part.state?.output : undefined}>
165
+ {(output) => <text fg={props.theme.textMuted}>{truncate(output().trim(), 300)}</text>}
166
+ </Show>
167
+ </>
168
+ )
169
+
170
+ const CodeToolPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
171
+ if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
172
+ if (props.part.tool === "edit") return <EditPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
173
+ if (props.part.tool === "write") return <WritePreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
174
+ return <CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />
175
+ }
176
+
177
+ const ApplyPatchPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
178
+ const files = createMemo(() => parseApplyPatchFiles(props.part.state?.metadata))
179
+ const matched = createMemo(() => {
180
+ const query = props.item.match || props.item.previewMatch
181
+ return files().find((file) => containsOrderedTokens(file.patch, query)) ?? files()[0]
182
+ })
183
+ return (
184
+ <Show when={matched()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
185
+ {(file) => {
186
+ const view = createMemo(() => clippedText(file().patch, props.item.match || props.item.previewMatch, 24))
187
+ return (
188
+ <ToolBlock title={patchTitle(file())} theme={props.theme}>
189
+ <Show when={view().clipped}>
190
+ <text fg={props.theme.textMuted}>showing matched excerpt from large patch</text>
191
+ </Show>
192
+ <DiffBlock diff={view().text} filePath={file().filePath} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
193
+ </ToolBlock>
194
+ )
195
+ }}
196
+ </Show>
197
+ )
198
+ }
199
+
200
+ const EditPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
201
+ const input = createMemo(() => recordValue(props.part.state?.input))
202
+ const metadata = createMemo(() => recordValue(props.part.state?.metadata))
203
+ const diff = createMemo(() => stringValue(metadata()?.diff) ?? stringValue(recordValue(metadata()?.filediff)?.patch) ?? "")
204
+ const filePath = createMemo(() => stringValue(input()?.filePath) ?? stringValue(recordValue(metadata()?.filediff)?.file) ?? "")
205
+ return (
206
+ <Show when={diff()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
207
+ {(value) => (
208
+ <ToolBlock title={`← Edit ${shortPath(filePath())}`} theme={props.theme}>
209
+ {(() => {
210
+ const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 24))
211
+ return (
212
+ <>
213
+ <Show when={view().clipped}>
214
+ <text fg={props.theme.textMuted}>showing matched excerpt from large diff</text>
215
+ </Show>
216
+ <DiffBlock diff={view().text} filePath={filePath()} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
217
+ </>
218
+ )
219
+ })()}
220
+ </ToolBlock>
221
+ )}
222
+ </Show>
223
+ )
224
+ }
225
+
226
+ const WritePreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
227
+ const input = createMemo(() => recordValue(props.part.state?.input))
228
+ const filePath = createMemo(() => stringValue(input()?.filePath) ?? "")
229
+ const content = createMemo(() => stringValue(input()?.content) ?? "")
230
+ return (
231
+ <Show when={content()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
232
+ {(value) => (
233
+ <ToolBlock title={`# Wrote ${shortPath(filePath())}`} theme={props.theme}>
234
+ {(() => {
235
+ const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 32))
236
+ return (
237
+ <>
238
+ <Show when={view().clipped}>
239
+ <text fg={props.theme.textMuted}>showing matched excerpt from large file</text>
240
+ </Show>
241
+ <line_number fg={props.theme.textMuted} minWidth={3} paddingRight={1}>
242
+ <code
243
+ conceal={false}
244
+ fg={props.theme.text}
245
+ filetype={filetype(filePath())}
246
+ syntaxStyle={props.syntax}
247
+ content={view().text}
248
+ />
249
+ </line_number>
250
+ </>
251
+ )
252
+ })()}
253
+ </ToolBlock>
254
+ )}
255
+ </Show>
256
+ )
257
+ }
258
+
259
+ const ToolBlock = (props: { title: string; children: any; theme: TuiThemeCurrent }) => (
260
+ <box
261
+ border={["left"]}
262
+ paddingTop={1}
263
+ paddingBottom={1}
264
+ paddingLeft={2}
265
+ marginTop={1}
266
+ gap={1}
267
+ backgroundColor={props.theme.backgroundPanel}
268
+ customBorderChars={splitBorderChars}
269
+ borderColor={props.theme.background}
270
+ flexDirection="column"
271
+ >
272
+ <text paddingLeft={3} fg={props.theme.textMuted}>{props.title}</text>
273
+ {props.children}
274
+ </box>
275
+ )
276
+
277
+ const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent; clipped?: boolean }) => (
278
+ <box paddingLeft={1}>
279
+ <Show when={!props.clipped} fallback={<code filetype="diff" syntaxStyle={props.syntax} content={props.diff} fg={props.theme.text} />}>
280
+ <diff
281
+ diff={props.diff}
282
+ view="unified"
283
+ filetype={filetype(props.filePath)}
284
+ syntaxStyle={props.syntax}
285
+ showLineNumbers={true}
286
+ width="100%"
287
+ wrapMode="word"
288
+ fg={props.theme.text}
289
+ addedBg={props.theme.diffAddedBg}
290
+ removedBg={props.theme.diffRemovedBg}
291
+ contextBg={props.theme.diffContextBg}
292
+ addedSignColor={props.theme.diffHighlightAdded}
293
+ removedSignColor={props.theme.diffHighlightRemoved}
294
+ lineNumberFg={props.theme.diffLineNumber}
295
+ lineNumberBg={props.theme.diffContextBg}
296
+ addedLineNumberBg={props.theme.diffAddedLineNumberBg}
297
+ removedLineNumberBg={props.theme.diffRemovedLineNumberBg}
298
+ />
299
+ </Show>
300
+ </box>
301
+ )
302
+
161
303
  const TargetMarker = (props: { part: ConversationPreviewPart; item?: SearchResult; role: string; time: number; theme: TuiThemeCurrent }) => (
162
304
  <box flexDirection="column" flexShrink={0}>
163
305
  <text fg={props.theme.warning} wrapMode="none" overflow="hidden">
@@ -273,3 +415,106 @@ function matchExcerpt(text: string, query: string, radius = 80) {
273
415
  after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
274
416
  }
275
417
  }
418
+
419
+ function clippedText(text: string, query: string, radiusLines: number) {
420
+ const lines = text.split("\n")
421
+ const tooLarge = text.length > 30000 || lines.length > 420
422
+ if (!tooLarge) return { text, clipped: false }
423
+
424
+ const matchLine = findOrderedTokenLine(lines, query)
425
+ if (matchLine === -1) {
426
+ return { text: lines.slice(0, radiusLines * 2).join("\n"), clipped: true }
427
+ }
428
+
429
+ const start = Math.max(0, matchLine - radiusLines)
430
+ const end = Math.min(lines.length, matchLine + radiusLines + 1)
431
+ return {
432
+ text: [
433
+ start > 0 ? `... ${start} lines omitted ...` : undefined,
434
+ ...lines.slice(start, end),
435
+ end < lines.length ? `... ${lines.length - end} lines omitted ...` : undefined,
436
+ ].filter(Boolean).join("\n"),
437
+ clipped: true,
438
+ }
439
+ }
440
+
441
+ function findOrderedTokenLine(lines: string[], query: string) {
442
+ const tokens = query.trim().split(/\s+/).filter(Boolean)
443
+ if (tokens.length === 0) return -1
444
+ for (let index = 0; index < lines.length; index++) {
445
+ if (containsOrderedTokens(lines[index]!, query)) return index
446
+ }
447
+ return -1
448
+ }
449
+
450
+ function containsOrderedTokens(text: string, query: string) {
451
+ const tokens = query.trim().split(/\s+/).filter(Boolean)
452
+ if (tokens.length === 0) return false
453
+ const lower = text.toLowerCase()
454
+ let searchPos = 0
455
+ for (const token of tokens) {
456
+ const index = lower.indexOf(token.toLowerCase(), searchPos)
457
+ if (index === -1) return false
458
+ searchPos = index + token.length
459
+ }
460
+ return true
461
+ }
462
+
463
+ function parseApplyPatchFiles(metadata: unknown) {
464
+ const files = recordValue(metadata)?.files
465
+ if (!Array.isArray(files)) return []
466
+ return files.flatMap((item) => {
467
+ const file = recordValue(item)
468
+ const filePath = stringValue(file?.filePath)
469
+ const relativePath = stringValue(file?.relativePath) ?? filePath
470
+ const patch = stringValue(file?.patch)
471
+ const type = stringValue(file?.type) ?? "update"
472
+ const deletions = numberValue(file?.deletions) ?? 0
473
+ if (!filePath || !relativePath || patch === undefined) return []
474
+ return [{ filePath, relativePath, patch, type, deletions }]
475
+ })
476
+ }
477
+
478
+ function patchTitle(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
479
+ if (file.type === "delete") return `# Deleted ${file.relativePath}`
480
+ if (file.type === "add") return `# Created ${file.relativePath}`
481
+ if (file.type === "move") return `# Moved ${shortPath(file.filePath)} -> ${file.relativePath}`
482
+ return `← Patched ${file.relativePath}`
483
+ }
484
+
485
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
486
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
487
+ return value as Record<string, unknown>
488
+ }
489
+
490
+ function stringValue(value: unknown) {
491
+ return typeof value === "string" ? value : undefined
492
+ }
493
+
494
+ function numberValue(value: unknown) {
495
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
496
+ }
497
+
498
+ function shortPath(value: string) {
499
+ if (!value) return "file"
500
+ const parts = value.split(/[\\/]/)
501
+ return parts.slice(-3).join("/")
502
+ }
503
+
504
+ function filetype(input: string) {
505
+ const ext = input.split(".").at(-1)?.toLowerCase()
506
+ if (!ext || ext === input.toLowerCase()) return "none"
507
+ if (["ts", "tsx", "js", "jsx", "mts", "cts"].includes(ext)) return "typescript"
508
+ if (ext === "py") return "python"
509
+ if (ext === "go") return "go"
510
+ if (ext === "rs") return "rust"
511
+ if (ext === "rb") return "ruby"
512
+ if (ext === "java") return "java"
513
+ if (ext === "json") return "json"
514
+ if (ext === "md") return "markdown"
515
+ if (ext === "yml" || ext === "yaml") return "yaml"
516
+ if (ext === "sql") return "sql"
517
+ if (ext === "sh" || ext === "bash" || ext === "zsh") return "shellscript"
518
+ if (ext === "diff" || ext === "patch") return "diff"
519
+ return ext
520
+ }
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.16",
4
+ "version": "0.1.18",
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
@@ -11,6 +11,8 @@ export type SearchResult = {
11
11
  sessionTitle: string
12
12
  directory: string
13
13
  role: "user" | "assistant"
14
+ partType: "text" | "reasoning" | "tool"
15
+ tool?: string
14
16
  timeCreated: number
15
17
  snippet: string
16
18
  matchStart: number
@@ -56,6 +58,7 @@ export type ConversationPreviewCursor = {
56
58
  export type ToolState = {
57
59
  status: "pending" | "running" | "completed" | "error"
58
60
  input?: unknown
61
+ metadata?: unknown
59
62
  output?: string
60
63
  error?: string
61
64
  }
@@ -67,10 +70,16 @@ type Row = {
67
70
  session_title: string | null
68
71
  directory: string
69
72
  role: SearchRole
73
+ part_type?: SearchResult["partType"]
74
+ tool?: string | null
70
75
  time_created: number
71
76
  text: string
72
77
  }
73
78
 
79
+ type IndexSourceRow = Omit<Row, "text"> & {
80
+ data: string
81
+ }
82
+
74
83
  type ConversationRow = {
75
84
  id: string
76
85
  message_id: string
@@ -222,7 +231,11 @@ export function loadConversationAround(result: SearchResult, options?: { before?
222
231
  fetchBefore,
223
232
  fetchAfter,
224
233
  beforeRows: beforeRows.length,
234
+ validBefore: validBefore.length,
235
+ invalidBefore: previewRowBreakdown(beforeRows, validBefore),
225
236
  afterRows: afterRows.length,
237
+ validAfter: validAfter.length,
238
+ invalidAfter: previewRowBreakdown(afterRows, validAfter),
226
239
  parts: parts.length,
227
240
  hasMoreBefore: page.hasMoreBefore,
228
241
  hasMoreAfter: page.hasMoreAfter,
@@ -253,6 +266,7 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
253
266
  `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
254
267
  const valid = rows.filter(isPreviewRow)
255
268
  const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
269
+ const parsedIds = new Set(parts.map((part) => part.id))
256
270
  const page = { parts, hasMoreBefore: valid.length > limit }
257
271
  debug.log("preview:window", {
258
272
  item: result.id,
@@ -261,7 +275,10 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
261
275
  cursor,
262
276
  limit,
263
277
  rows: rows.length,
278
+ valid: valid.length,
279
+ invalid: previewRowBreakdown(rows, valid),
264
280
  parts: parts.length,
281
+ unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
265
282
  hasMoreBefore: page.hasMoreBefore,
266
283
  first: parts[0]?.id,
267
284
  last: parts.at(-1)?.id,
@@ -289,6 +306,7 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
289
306
  `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
290
307
  const valid = rows.filter(isPreviewRow)
291
308
  const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
309
+ const parsedIds = new Set(parts.map((part) => part.id))
292
310
  const page = { parts, hasMoreAfter: valid.length > limit }
293
311
  debug.log("preview:window", {
294
312
  item: result.id,
@@ -297,7 +315,10 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
297
315
  cursor,
298
316
  limit,
299
317
  rows: rows.length,
318
+ valid: valid.length,
319
+ invalid: previewRowBreakdown(rows, valid),
300
320
  parts: parts.length,
321
+ unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
301
322
  hasMoreAfter: page.hasMoreAfter,
302
323
  first: parts[0]?.id,
303
324
  last: parts.at(-1)?.id,
@@ -318,6 +339,8 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
318
339
  sessionTitle: row.session_title || "Untitled session",
319
340
  directory: row.directory,
320
341
  role: row.role,
342
+ partType: row.part_type ?? "text",
343
+ tool: row.tool ?? undefined,
321
344
  timeCreated: row.time_created,
322
345
  snippet: makeSnippet(text, query),
323
346
  matchStart: match.start,
@@ -490,7 +513,7 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
490
513
  role: row.role,
491
514
  type: row.type,
492
515
  timeCreated: row.time_created,
493
- text: "",
516
+ text: target ? extractToolIndexText(data).trim() : "",
494
517
  tool: typeof data.tool === "string" ? data.tool : "tool",
495
518
  state: parseToolState(data.state),
496
519
  target,
@@ -515,6 +538,21 @@ function isPreviewRow(row: ConversationRow) {
515
538
  (row.type === "text" || row.type === "reasoning" || row.type === "tool")
516
539
  }
517
540
 
541
+ function previewRowBreakdown(rows: ConversationRow[], validRows: ConversationRow[]) {
542
+ const valid = new Set(validRows.map((row) => row.id))
543
+ const invalidRows = rows.filter((row) => !valid.has(row.id))
544
+ if (invalidRows.length === 0) return undefined
545
+ const byRole = countBy(invalidRows, (row) => String(row.role ?? "unknown"))
546
+ const byType = countBy(invalidRows, (row) => String(row.type ?? "unknown"))
547
+ return { count: invalidRows.length, byRole, byType }
548
+ }
549
+
550
+ function countBy<T>(items: T[], key: (item: T) => string) {
551
+ const counts: Record<string, number> = {}
552
+ for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1
553
+ return counts
554
+ }
555
+
518
556
  function parsePartData(data: string) {
519
557
  try {
520
558
  const value = JSON.parse(data) as unknown
@@ -532,6 +570,7 @@ function parseToolState(value: unknown): ToolState | undefined {
532
570
  return {
533
571
  status: state.status as ToolState["status"],
534
572
  input: state.input,
573
+ metadata: state.metadata,
535
574
  output: typeof state.output === "string" ? state.output : undefined,
536
575
  error: typeof state.error === "string" ? state.error : undefined,
537
576
  }
@@ -568,7 +607,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
568
607
  const offsetClause = offset ? "OFFSET ?" : ""
569
608
  debug.time("query:fts:exec")
570
609
  const rows = index.query<Row, (string | number)[]>(`
571
- SELECT id, message_id, session_id, session_title, directory, role,
610
+ SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
572
611
  CAST(time_created AS INTEGER) AS time_created, text
573
612
  FROM document_fts
574
613
  WHERE ${conditions.join(" AND ")}
@@ -638,15 +677,18 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
638
677
  const currentDataVersion = getMeta(_indexDb, "source_data_version")
639
678
  const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
640
679
  const currentPath = getMeta(_indexDb, "source_path")
641
- if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs)) {
680
+ const currentIndexVersion = getMeta(_indexDb, "index_version")
681
+ if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
642
682
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
643
683
  }
644
684
  return _indexDb
645
685
  }
646
686
 
687
+ const SEARCH_INDEX_VERSION = "4"
688
+
647
689
  function searchIndexPath(sourcePath: string) {
648
690
  const parsed = path.parse(sourcePath)
649
- return path.join(parsed.dir, `${parsed.name}-search.db`)
691
+ return path.join(parsed.dir, `${parsed.name}-telescope-search.db`)
650
692
  }
651
693
 
652
694
  function migrateSearchIndex(db: Database) {
@@ -662,11 +704,33 @@ function migrateSearchIndex(db: Database) {
662
704
  session_title,
663
705
  directory UNINDEXED,
664
706
  role UNINDEXED,
707
+ part_type UNINDEXED,
708
+ tool UNINDEXED,
665
709
  time_created UNINDEXED,
666
710
  text,
667
711
  tokenize='unicode61'
668
712
  );
669
713
  `)
714
+
715
+ const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
716
+ if (!columns.includes("part_type") || !columns.includes("tool")) {
717
+ db.exec("DROP TABLE document_fts")
718
+ db.exec(`
719
+ CREATE VIRTUAL TABLE document_fts USING fts5(
720
+ id UNINDEXED,
721
+ message_id UNINDEXED,
722
+ session_id UNINDEXED,
723
+ session_title,
724
+ directory UNINDEXED,
725
+ role UNINDEXED,
726
+ part_type UNINDEXED,
727
+ tool UNINDEXED,
728
+ time_created UNINDEXED,
729
+ text,
730
+ tokenize='unicode61'
731
+ );
732
+ `)
733
+ }
670
734
  }
671
735
 
672
736
  function sourceState(db: Database, sourcePath: string) {
@@ -677,27 +741,34 @@ function sourceState(db: Database, sourcePath: string) {
677
741
 
678
742
  function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
679
743
  debug.time("fts:rebuild")
680
- const rows = source.query<Row, []>(`
744
+ const rows = source.query<IndexSourceRow, []>(`
681
745
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
682
746
  json_extract(m.data, '$.role') AS role,
747
+ json_extract(p.data, '$.type') AS part_type,
748
+ json_extract(p.data, '$.tool') AS tool,
683
749
  p.time_created,
684
- json_extract(p.data, '$.text') AS text
750
+ p.data
685
751
  FROM part p
686
752
  JOIN message m ON m.id = p.message_id
687
753
  JOIN session s ON s.id = p.session_id
688
- WHERE json_extract(p.data, '$.type') = 'text'
754
+ WHERE (
755
+ json_extract(p.data, '$.type') = 'text'
756
+ OR (
757
+ json_extract(p.data, '$.type') = 'tool'
758
+ AND json_extract(p.data, '$.tool') IN ('apply_patch', 'edit', 'write')
759
+ )
760
+ )
689
761
  AND json_extract(m.data, '$.role') IN ('user', 'assistant')
690
- AND json_extract(p.data, '$.text') IS NOT NULL
691
762
  ORDER BY p.time_created DESC
692
763
  `).all()
693
- const insert = index.query<Row, [string, string, string, string, string, string, number, string]>(`
694
- INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, time_created, text)
695
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
764
+ const insert = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
765
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
766
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
696
767
  `)
697
768
  index.exec("BEGIN IMMEDIATE")
698
769
  try {
699
770
  index.exec("DELETE FROM document_fts")
700
- for (const row of rows) {
771
+ for (const row of rows.flatMap(indexSourceRowToRows)) {
701
772
  insert.run(
702
773
  row.id,
703
774
  row.message_id,
@@ -705,6 +776,8 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
705
776
  row.session_title ?? "Untitled session",
706
777
  row.directory,
707
778
  row.role,
779
+ row.part_type ?? "text",
780
+ row.tool ?? null,
708
781
  row.time_created,
709
782
  row.text,
710
783
  )
@@ -712,6 +785,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
712
785
  setMeta(index, "source_path", sourcePath)
713
786
  setMeta(index, "source_data_version", String(state.dataVersion))
714
787
  setMeta(index, "source_mtime_ms", String(state.mtimeMs))
788
+ setMeta(index, "index_version", SEARCH_INDEX_VERSION)
715
789
  index.exec("COMMIT")
716
790
  } catch (err) {
717
791
  index.exec("ROLLBACK")
@@ -740,6 +814,75 @@ function setMeta(db: Database, key: string, value: string) {
740
814
  `).run(key, value)
741
815
  }
742
816
 
817
+ function indexSourceRowToRows(row: IndexSourceRow): Row[] {
818
+ const text = extractIndexText(row.data)
819
+ if (!text) return []
820
+ return [{ ...row, text }]
821
+ }
822
+
823
+ function extractIndexText(data: string) {
824
+ try {
825
+ const value = JSON.parse(data) as unknown
826
+ if (!value || typeof value !== "object" || Array.isArray(value)) return ""
827
+ const record = value as Record<string, unknown>
828
+ if (record.type === "text") return typeof record.text === "string" ? record.text.trim() : ""
829
+ if (record.type !== "tool") return ""
830
+ return extractToolIndexText(record).replace(/\s+/g, " ").trim()
831
+ } catch {
832
+ return ""
833
+ }
834
+ }
835
+
836
+ function extractToolIndexText(part: Record<string, unknown>) {
837
+ const tool = typeof part.tool === "string" ? part.tool : ""
838
+ const state = recordValue(part.state)
839
+ const input = recordValue(state?.input)
840
+ const metadata = recordValue(state?.metadata)
841
+
842
+ if (tool === "apply_patch") {
843
+ const files = Array.isArray(metadata?.files) ? metadata.files : []
844
+ const renderedPatches = files.map(applyPatchFileIndexText).filter(Boolean).join("\n")
845
+ const patchText = stringValue(input?.patchText)
846
+ return [renderedPatches, patchText].filter(Boolean).join("\n")
847
+ }
848
+
849
+ if (tool === "edit") {
850
+ const filediff = recordValue(metadata?.filediff)
851
+ return [
852
+ stringValue(input?.filePath),
853
+ stringValue(metadata?.diff),
854
+ stringValue(filediff?.patch),
855
+ stringValue(input?.oldString),
856
+ stringValue(input?.newString),
857
+ ].filter(Boolean).join("\n")
858
+ }
859
+
860
+ if (tool === "write") {
861
+ return [stringValue(input?.filePath), stringValue(input?.content)].filter(Boolean).join("\n")
862
+ }
863
+
864
+ return ""
865
+ }
866
+
867
+ function applyPatchFileIndexText(value: unknown) {
868
+ const file = recordValue(value)
869
+ if (!file) return ""
870
+ return [
871
+ stringValue(file.filePath),
872
+ stringValue(file.relativePath),
873
+ stringValue(file.patch),
874
+ ].filter(Boolean).join("\n")
875
+ }
876
+
877
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
878
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
879
+ return value as Record<string, unknown>
880
+ }
881
+
882
+ function stringValue(value: unknown) {
883
+ return typeof value === "string" ? value : undefined
884
+ }
885
+
743
886
  const tableCache = new Map<string, boolean>()
744
887
  function tableExists(db: Database, name: string) {
745
888
  if (tableCache.has(name)) return tableCache.get(name)!
package/telescope.tsx CHANGED
@@ -432,18 +432,55 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
432
432
  })
433
433
 
434
434
  const previewContentHeight = () => {
435
- const children = previewScroll?.getChildren()
435
+ return previewScroll?.scrollHeight ?? 0
436
+ }
437
+
438
+ const previewScrollState = () => {
439
+ const scroll = previewScroll
440
+ const children = scroll?.getChildren()
436
441
  const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
437
- return lastChild ? lastChild.y + lastChild.height : 0
442
+ return {
443
+ y: scroll?.y,
444
+ height: scroll?.height,
445
+ scrollTop: scroll?.scrollTop,
446
+ scrollHeight: scroll?.scrollHeight,
447
+ childContentHeight: lastChild ? lastChild.y + lastChild.height : 0,
448
+ children: children?.length ?? 0,
449
+ hasMoreBefore: hasMorePreviewBefore(),
450
+ hasMoreAfter: hasMorePreviewAfter(),
451
+ loadingMore: loadingPreviewMore(),
452
+ prefetchingBefore: prefetchingPreviewBefore(),
453
+ prefetchingAfter: prefetchingPreviewAfter(),
454
+ timerBefore: Boolean(previewBeforeTimer),
455
+ timerAfter: Boolean(previewAfterTimer),
456
+ }
438
457
  }
439
458
 
440
459
  const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
441
460
  const item = selectedResult()
442
461
  const first = previewParts()[0]
443
- if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
462
+ if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) {
463
+ debug.log("preview:load-before:skip", {
464
+ reason: !item ? "no-item" : !first ? "no-first-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
465
+ previousContentHeight,
466
+ preserveScroll,
467
+ visibleLoad,
468
+ state: previewScrollState(),
469
+ })
470
+ return
471
+ }
472
+ const beforeState = previewScrollState()
444
473
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
445
474
  debug.time("preview:load-before")
446
475
  try {
476
+ debug.log("preview:load-before:start", {
477
+ item: item.id,
478
+ cursor: { id: first.id, timeCreated: first.timeCreated },
479
+ previousContentHeight,
480
+ preserveScroll,
481
+ visibleLoad,
482
+ state: beforeState,
483
+ })
447
484
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
448
485
  debug.log("preview:load-before", {
449
486
  item: item.id,
@@ -458,8 +495,25 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
458
495
  setPreviewParts((prev) => [...page.parts, ...prev])
459
496
  if (preserveScroll) {
460
497
  setTimeout(() => {
498
+ const beforeAdjust = previewScrollState()
461
499
  const delta = previewContentHeight() - previousContentHeight
462
500
  if (delta > 0) previewScroll?.scrollBy(delta)
501
+ debug.log("preview:load-before:adjust", {
502
+ delta,
503
+ previousContentHeight,
504
+ newContentHeight: previewContentHeight(),
505
+ scrolled: delta > 0,
506
+ beforeAdjust,
507
+ afterAdjust: previewScrollState(),
508
+ })
509
+ }, 1)
510
+ } else {
511
+ setTimeout(() => {
512
+ debug.log("preview:load-before:no-adjust", {
513
+ previousContentHeight,
514
+ newContentHeight: previewContentHeight(),
515
+ state: previewScrollState(),
516
+ })
463
517
  }, 1)
464
518
  }
465
519
  }
@@ -475,7 +529,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
475
529
  const loadPreviewAfter = (visibleLoad = false) => {
476
530
  const item = selectedResult()
477
531
  const last = previewParts().at(-1)
478
- if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
532
+ if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) {
533
+ debug.log("preview:load-after:skip", {
534
+ reason: !item ? "no-item" : !last ? "no-last-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
535
+ visibleLoad,
536
+ state: previewScrollState(),
537
+ })
538
+ return
539
+ }
540
+ debug.log("preview:load-after:start", {
541
+ item: item.id,
542
+ cursor: { id: last.id, timeCreated: last.timeCreated },
543
+ visibleLoad,
544
+ state: previewScrollState(),
545
+ })
479
546
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
480
547
  debug.time("preview:load-after")
481
548
  try {
@@ -499,16 +566,40 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
499
566
  }
500
567
 
501
568
  const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
502
- if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
569
+ if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) {
570
+ debug.log("preview:prefetch-before:skip", {
571
+ reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
572
+ previousContentHeight,
573
+ preserveScroll,
574
+ visibleLoad,
575
+ state: previewScrollState(),
576
+ })
577
+ return
578
+ }
503
579
  if (previewBeforeTimer) {
504
580
  if (pendingPreviewBefore) {
581
+ const previousPending = { ...pendingPreviewBefore }
505
582
  pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
506
583
  pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
584
+ debug.log("preview:prefetch-before:merge", {
585
+ previousPending,
586
+ nextPending: pendingPreviewBefore,
587
+ requested: { previousContentHeight, preserveScroll, visibleLoad },
588
+ state: previewScrollState(),
589
+ })
590
+ } else {
591
+ debug.log("preview:prefetch-before:skip", {
592
+ reason: "timer-already-set",
593
+ previousContentHeight,
594
+ preserveScroll,
595
+ visibleLoad,
596
+ state: previewScrollState(),
597
+ })
507
598
  }
508
599
  return
509
600
  }
510
601
  pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
- debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
602
+ debug.log("preview:prefetch-before-scheduled", { previousContentHeight, preserveScroll, visibleLoad, state: previewScrollState() })
512
603
  previewBeforeTimer = setTimeout(() => {
513
604
  const pending = pendingPreviewBefore
514
605
  previewBeforeTimer = undefined
@@ -518,10 +609,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
518
609
  }
519
610
 
520
611
  const schedulePreviewAfter = (visibleLoad = false) => {
521
- if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
612
+ if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) {
613
+ debug.log("preview:prefetch-after:skip", {
614
+ reason: !hasMorePreviewAfter() ? "no-more-after" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
615
+ visibleLoad,
616
+ state: previewScrollState(),
617
+ })
618
+ return
619
+ }
522
620
  pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
523
- if (previewAfterTimer) return
524
- debug.log("preview:prefetch-after-scheduled", { visibleLoad })
621
+ if (previewAfterTimer) {
622
+ debug.log("preview:prefetch-after:skip", { reason: "timer-already-set", visibleLoad, pendingVisibleLoad: pendingPreviewAfterVisible, state: previewScrollState() })
623
+ return
624
+ }
625
+ debug.log("preview:prefetch-after-scheduled", { visibleLoad, state: previewScrollState() })
525
626
  previewAfterTimer = setTimeout(() => {
526
627
  const pendingVisibleLoad = pendingPreviewAfterVisible
527
628
  previewAfterTimer = undefined
@@ -573,18 +674,19 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
573
674
  const scroll = previewScroll
574
675
  const children = scroll?.getChildren()
575
676
  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
677
+ const totalContentHeight = scroll.scrollHeight
678
+ const atTop = scroll.scrollTop <= 0
579
679
  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
680
+ const nearTop = scroll.scrollTop <= prefetchDistance
681
+ const atBottom = scroll.scrollTop + scroll.height >= totalContentHeight - 1
682
+ const nearBottom = scroll.scrollTop + scroll.height >= totalContentHeight - prefetchDistance
583
683
  if (nearTop || nearBottom) {
584
684
  debug.log("preview:scroll-edge", {
585
685
  y: scroll.y,
686
+ scrollTop: scroll.scrollTop,
586
687
  height: scroll.height,
587
688
  contentHeight: totalContentHeight,
689
+ childContentHeight: previewScrollState().childContentHeight,
588
690
  prefetchDistance,
589
691
  atTop,
590
692
  nearTop,
@@ -597,7 +699,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
597
699
  children: children.length,
598
700
  })
599
701
  }
600
- if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, false, true)
702
+ if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, true, false)
601
703
  if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
602
704
  }, 400)
603
705
  onCleanup(() => clearInterval(interval))
@@ -625,7 +727,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
625
727
 
626
728
  const scrollPreview = (direction: 1 | -1, evt: ParsedKey) => {
627
729
  prevent(evt)
628
- previewScroll?.scrollBy(direction * previewScrollAmount(previewScroll))
730
+ const scroll = previewScroll
731
+ if (!scroll) {
732
+ debug.log("preview:scroll-key:skip", { reason: "no-scroll", direction })
733
+ return
734
+ }
735
+
736
+ const beforeState = previewScrollState()
737
+ debug.log("preview:scroll-key", { direction, state: beforeState })
738
+
739
+ if (direction < 0 && scroll.scrollTop <= 0 && hasMorePreviewBefore()) {
740
+ debug.log("preview:scroll-key:load-before", { direction, state: beforeState })
741
+ schedulePreviewBefore(previewContentHeight(), true, true)
742
+ return
743
+ }
744
+
745
+ const totalContentHeight = scroll.scrollHeight
746
+ if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
747
+ debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
748
+ schedulePreviewAfter(true)
749
+ return
750
+ }
751
+
752
+ const amount = direction * previewScrollAmount(scroll)
753
+ scroll.scrollBy(amount)
754
+ debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
629
755
  }
630
756
 
631
757
  const focusInput = () => {
@@ -1,20 +1,48 @@
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))
6
7
  }
7
8
 
8
9
  export function messageTargetID(item: SearchResult) {
10
+ if (item.partType === "tool") return `tool-${item.messageID}-${item.id}`
11
+ if (item.partType === "reasoning") return `text-${item.messageID}-${item.id}`
9
12
  if (item.role === "assistant") return `text-${item.messageID}-${item.id}`
10
13
  return item.messageID
11
14
  }
12
15
 
13
16
  export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, targetID: string) {
14
- if (!scroll) return
17
+ if (!scroll) {
18
+ debug.log("preview:target-scroll:skip", { reason: "no-scroll", targetID })
19
+ return
20
+ }
15
21
  const target = findRenderableByID(scroll, targetID)
16
- if (!target) return
17
- 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 })
18
46
  }
19
47
 
20
48
  export function jumpToRenderedTarget(root: unknown, targetID: string) {