@bojackduy/opencode-telescope 0.1.15 → 0.1.17

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,139 @@ 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()} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
147
+ <CodeToolPreview part={props.part} 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; 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} />
174
+ return <CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />
175
+ }
176
+
177
+ const ApplyPatchPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
178
+ const files = createMemo(() => parseApplyPatchFiles(props.part.state?.metadata))
179
+ 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} />
185
+ </ToolBlock>
186
+ )}
187
+ </For>
188
+ </Show>
189
+ )
190
+ }
191
+
192
+ const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
193
+ const input = createMemo(() => recordValue(props.part.state?.input))
194
+ const metadata = createMemo(() => recordValue(props.part.state?.metadata))
195
+ const diff = createMemo(() => stringValue(metadata()?.diff) ?? stringValue(recordValue(metadata()?.filediff)?.patch) ?? "")
196
+ const filePath = createMemo(() => stringValue(input()?.filePath) ?? stringValue(recordValue(metadata()?.filediff)?.file) ?? "")
197
+ return (
198
+ <Show when={diff()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
199
+ {(value) => (
200
+ <ToolBlock title={`← Edit ${shortPath(filePath())}`} theme={props.theme}>
201
+ <DiffBlock diff={value()} filePath={filePath()} syntax={props.syntax} theme={props.theme} />
202
+ </ToolBlock>
203
+ )}
204
+ </Show>
205
+ )
206
+ }
207
+
208
+ const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
209
+ const input = createMemo(() => recordValue(props.part.state?.input))
210
+ const filePath = createMemo(() => stringValue(input()?.filePath) ?? "")
211
+ const content = createMemo(() => stringValue(input()?.content) ?? "")
212
+ return (
213
+ <Show when={content()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
214
+ {(value) => (
215
+ <ToolBlock title={`# Wrote ${shortPath(filePath())}`} theme={props.theme}>
216
+ <line_number fg={props.theme.textMuted} minWidth={3} paddingRight={1}>
217
+ <code
218
+ conceal={false}
219
+ fg={props.theme.text}
220
+ filetype={filetype(filePath())}
221
+ syntaxStyle={props.syntax}
222
+ content={value()}
223
+ />
224
+ </line_number>
225
+ </ToolBlock>
226
+ )}
227
+ </Show>
228
+ )
229
+ }
230
+
231
+ const ToolBlock = (props: { title: string; children: any; theme: TuiThemeCurrent }) => (
232
+ <box
233
+ border={["left"]}
234
+ paddingTop={1}
235
+ paddingBottom={1}
236
+ paddingLeft={2}
237
+ marginTop={1}
238
+ gap={1}
239
+ backgroundColor={props.theme.backgroundPanel}
240
+ customBorderChars={splitBorderChars}
241
+ borderColor={props.theme.background}
242
+ flexDirection="column"
243
+ >
244
+ <text paddingLeft={3} fg={props.theme.textMuted}>{props.title}</text>
245
+ {props.children}
246
+ </box>
247
+ )
248
+
249
+ const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => (
250
+ <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
+ />
270
+ </box>
271
+ )
272
+
161
273
  const TargetMarker = (props: { part: ConversationPreviewPart; item?: SearchResult; role: string; time: number; theme: TuiThemeCurrent }) => (
162
274
  <box flexDirection="column" flexShrink={0}>
163
275
  <text fg={props.theme.warning} wrapMode="none" overflow="hidden">
@@ -273,3 +385,62 @@ function matchExcerpt(text: string, query: string, radius = 80) {
273
385
  after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
274
386
  }
275
387
  }
388
+
389
+ function parseApplyPatchFiles(metadata: unknown) {
390
+ const files = recordValue(metadata)?.files
391
+ if (!Array.isArray(files)) return []
392
+ return files.flatMap((item) => {
393
+ const file = recordValue(item)
394
+ const filePath = stringValue(file?.filePath)
395
+ const relativePath = stringValue(file?.relativePath) ?? filePath
396
+ const patch = stringValue(file?.patch)
397
+ const type = stringValue(file?.type) ?? "update"
398
+ const deletions = numberValue(file?.deletions) ?? 0
399
+ if (!filePath || !relativePath || patch === undefined) return []
400
+ return [{ filePath, relativePath, patch, type, deletions }]
401
+ })
402
+ }
403
+
404
+ function patchTitle(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
405
+ if (file.type === "delete") return `# Deleted ${file.relativePath}`
406
+ if (file.type === "add") return `# Created ${file.relativePath}`
407
+ if (file.type === "move") return `# Moved ${shortPath(file.filePath)} -> ${file.relativePath}`
408
+ return `← Patched ${file.relativePath}`
409
+ }
410
+
411
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
412
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
413
+ return value as Record<string, unknown>
414
+ }
415
+
416
+ function stringValue(value: unknown) {
417
+ return typeof value === "string" ? value : undefined
418
+ }
419
+
420
+ function numberValue(value: unknown) {
421
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
422
+ }
423
+
424
+ function shortPath(value: string) {
425
+ if (!value) return "file"
426
+ const parts = value.split(/[\\/]/)
427
+ return parts.slice(-3).join("/")
428
+ }
429
+
430
+ function filetype(input: string) {
431
+ const ext = input.split(".").at(-1)?.toLowerCase()
432
+ if (!ext || ext === input.toLowerCase()) return "none"
433
+ if (["ts", "tsx", "js", "jsx", "mts", "cts"].includes(ext)) return "typescript"
434
+ if (ext === "py") return "python"
435
+ if (ext === "go") return "go"
436
+ if (ext === "rs") return "rust"
437
+ if (ext === "rb") return "ruby"
438
+ if (ext === "java") return "java"
439
+ if (ext === "json") return "json"
440
+ if (ext === "md") return "markdown"
441
+ if (ext === "yml" || ext === "yaml") return "yaml"
442
+ if (ext === "sql") return "sql"
443
+ if (ext === "sh" || ext === "bash" || ext === "zsh") return "shellscript"
444
+ if (ext === "diff" || ext === "patch") return "diff"
445
+ return ext
446
+ }
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.15",
4
+ "version": "0.1.17",
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,7 +11,8 @@ export type SearchResult = {
11
11
  sessionTitle: string
12
12
  directory: string
13
13
  role: "user" | "assistant"
14
- partType: "text" | "reasoning" | "tool" | "patch"
14
+ partType: "text" | "reasoning" | "tool"
15
+ tool?: string
15
16
  timeCreated: number
16
17
  snippet: string
17
18
  matchStart: number
@@ -57,6 +58,7 @@ export type ConversationPreviewCursor = {
57
58
  export type ToolState = {
58
59
  status: "pending" | "running" | "completed" | "error"
59
60
  input?: unknown
61
+ metadata?: unknown
60
62
  output?: string
61
63
  error?: string
62
64
  }
@@ -69,11 +71,12 @@ type Row = {
69
71
  directory: string
70
72
  role: SearchRole
71
73
  part_type?: SearchResult["partType"]
74
+ tool?: string | null
72
75
  time_created: number
73
76
  text: string
74
77
  }
75
78
 
76
- type SourceRow = Omit<Row, "text"> & {
79
+ type IndexSourceRow = Omit<Row, "text"> & {
77
80
  data: string
78
81
  }
79
82
 
@@ -325,6 +328,7 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
325
328
  directory: row.directory,
326
329
  role: row.role,
327
330
  partType: row.part_type ?? "text",
331
+ tool: row.tool ?? undefined,
328
332
  timeCreated: row.time_created,
329
333
  snippet: makeSnippet(text, query),
330
334
  matchStart: match.start,
@@ -344,7 +348,7 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
344
348
 
345
349
  export function extractSearchText(data: string) {
346
350
  try {
347
- return searchablePartText(JSON.parse(data)).replace(/\s+/g, " ").trim()
351
+ return extractFromValue(JSON.parse(data)).replace(/\s+/g, " ").trim()
348
352
  } catch {
349
353
  return data.replace(/\s+/g, " ").trim()
350
354
  }
@@ -497,7 +501,7 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
497
501
  role: row.role,
498
502
  type: row.type,
499
503
  timeCreated: row.time_created,
500
- text: "",
504
+ text: extractToolIndexText(data).trim(),
501
505
  tool: typeof data.tool === "string" ? data.tool : "tool",
502
506
  state: parseToolState(data.state),
503
507
  target,
@@ -539,6 +543,7 @@ function parseToolState(value: unknown): ToolState | undefined {
539
543
  return {
540
544
  status: state.status as ToolState["status"],
541
545
  input: state.input,
546
+ metadata: state.metadata,
542
547
  output: typeof state.output === "string" ? state.output : undefined,
543
548
  error: typeof state.error === "string" ? state.error : undefined,
544
549
  }
@@ -575,8 +580,8 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
575
580
  const offsetClause = offset ? "OFFSET ?" : ""
576
581
  debug.time("query:fts:exec")
577
582
  const rows = index.query<Row, (string | number)[]>(`
578
- SELECT id, message_id, session_id, session_title, directory, role,
579
- part_type, CAST(time_created AS INTEGER) AS time_created, text
583
+ SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
584
+ CAST(time_created AS INTEGER) AS time_created, text
580
585
  FROM document_fts
581
586
  WHERE ${conditions.join(" AND ")}
582
587
  ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
@@ -593,7 +598,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
593
598
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
594
599
  const offsetClause = offset ? "OFFSET ?" : ""
595
600
  const conditions: string[] = [
596
- "json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool', 'patch')",
601
+ "json_extract(p.data, '$.type') = 'text'",
597
602
  role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
598
603
  ]
599
604
  const params: (string | number)[] = []
@@ -607,7 +612,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
607
612
 
608
613
  const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
609
614
  for (const token of tokens) {
610
- conditions.push("p.data LIKE ?")
615
+ conditions.push("json_extract(p.data, '$.text') LIKE ?")
611
616
  params.push(`%${token}%`)
612
617
  }
613
618
 
@@ -617,9 +622,8 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
617
622
  const sql = `
618
623
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
619
624
  json_extract(m.data, '$.role') AS role,
620
- json_extract(p.data, '$.type') AS part_type,
621
625
  p.time_created,
622
- p.data
626
+ json_extract(p.data, '$.text') AS text
623
627
  FROM part p
624
628
  JOIN message m ON m.id = p.message_id
625
629
  JOIN session s ON s.id = p.session_id
@@ -628,9 +632,9 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
628
632
  LIMIT ? ${offsetClause}
629
633
  `
630
634
  debug.time("query:sql:exec")
631
- const rows = db.query<SourceRow, (string | number)[]>(sql).all(...params as any[])
635
+ const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
632
636
  debug.timeEnd("query:sql:exec")
633
- return rows.flatMap(sourceRowToSearchRow)
637
+ return rows
634
638
  }
635
639
 
636
640
  function ensureSearchIndex(source: Database, sourcePath: string) {
@@ -653,11 +657,11 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
653
657
  return _indexDb
654
658
  }
655
659
 
656
- const SEARCH_INDEX_VERSION = "3"
660
+ const SEARCH_INDEX_VERSION = "4"
657
661
 
658
662
  function searchIndexPath(sourcePath: string) {
659
663
  const parsed = path.parse(sourcePath)
660
- return path.join(parsed.dir, `${parsed.name}-search.db`)
664
+ return path.join(parsed.dir, `${parsed.name}-telescope-search.db`)
661
665
  }
662
666
 
663
667
  function migrateSearchIndex(db: Database) {
@@ -674,14 +678,15 @@ function migrateSearchIndex(db: Database) {
674
678
  directory UNINDEXED,
675
679
  role UNINDEXED,
676
680
  part_type UNINDEXED,
681
+ tool UNINDEXED,
677
682
  time_created UNINDEXED,
678
683
  text,
679
684
  tokenize='unicode61'
680
685
  );
681
686
  `)
682
687
 
683
- const hasPartType = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().some((column) => column.name === "part_type")
684
- if (!hasPartType) {
688
+ const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
689
+ if (!columns.includes("part_type") || !columns.includes("tool")) {
685
690
  db.exec("DROP TABLE document_fts")
686
691
  db.exec(`
687
692
  CREATE VIRTUAL TABLE document_fts USING fts5(
@@ -692,6 +697,7 @@ function migrateSearchIndex(db: Database) {
692
697
  directory UNINDEXED,
693
698
  role UNINDEXED,
694
699
  part_type UNINDEXED,
700
+ tool UNINDEXED,
695
701
  time_created UNINDEXED,
696
702
  text,
697
703
  tokenize='unicode61'
@@ -708,27 +714,34 @@ function sourceState(db: Database, sourcePath: string) {
708
714
 
709
715
  function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
710
716
  debug.time("fts:rebuild")
711
- const rows = source.query<SourceRow, []>(`
717
+ const rows = source.query<IndexSourceRow, []>(`
712
718
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
713
- json_extract(m.data, '$.role') AS role,
714
- json_extract(p.data, '$.type') AS part_type,
715
- p.time_created,
716
- p.data
719
+ json_extract(m.data, '$.role') AS role,
720
+ json_extract(p.data, '$.type') AS part_type,
721
+ json_extract(p.data, '$.tool') AS tool,
722
+ p.time_created,
723
+ p.data
717
724
  FROM part p
718
725
  JOIN message m ON m.id = p.message_id
719
726
  JOIN session s ON s.id = p.session_id
720
- WHERE json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool', 'patch')
727
+ WHERE (
728
+ json_extract(p.data, '$.type') = 'text'
729
+ OR (
730
+ json_extract(p.data, '$.type') = 'tool'
731
+ AND json_extract(p.data, '$.tool') IN ('apply_patch', 'edit', 'write')
732
+ )
733
+ )
721
734
  AND json_extract(m.data, '$.role') IN ('user', 'assistant')
722
735
  ORDER BY p.time_created DESC
723
736
  `).all()
724
- const insert = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], number, string]>(`
725
- INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, time_created, text)
726
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
737
+ const insert = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
738
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
739
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
727
740
  `)
728
741
  index.exec("BEGIN IMMEDIATE")
729
742
  try {
730
743
  index.exec("DELETE FROM document_fts")
731
- for (const row of rows.flatMap(sourceRowToSearchRow)) {
744
+ for (const row of rows.flatMap(indexSourceRowToRows)) {
732
745
  insert.run(
733
746
  row.id,
734
747
  row.message_id,
@@ -737,6 +750,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
737
750
  row.directory,
738
751
  row.role,
739
752
  row.part_type ?? "text",
753
+ row.tool ?? null,
740
754
  row.time_created,
741
755
  row.text,
742
756
  )
@@ -773,6 +787,75 @@ function setMeta(db: Database, key: string, value: string) {
773
787
  `).run(key, value)
774
788
  }
775
789
 
790
+ function indexSourceRowToRows(row: IndexSourceRow): Row[] {
791
+ const text = extractIndexText(row.data)
792
+ if (!text) return []
793
+ return [{ ...row, text }]
794
+ }
795
+
796
+ function extractIndexText(data: string) {
797
+ try {
798
+ const value = JSON.parse(data) as unknown
799
+ if (!value || typeof value !== "object" || Array.isArray(value)) return ""
800
+ const record = value as Record<string, unknown>
801
+ if (record.type === "text") return typeof record.text === "string" ? record.text.trim() : ""
802
+ if (record.type !== "tool") return ""
803
+ return extractToolIndexText(record).replace(/\s+/g, " ").trim()
804
+ } catch {
805
+ return ""
806
+ }
807
+ }
808
+
809
+ function extractToolIndexText(part: Record<string, unknown>) {
810
+ const tool = typeof part.tool === "string" ? part.tool : ""
811
+ const state = recordValue(part.state)
812
+ const input = recordValue(state?.input)
813
+ const metadata = recordValue(state?.metadata)
814
+
815
+ if (tool === "apply_patch") {
816
+ const files = Array.isArray(metadata?.files) ? metadata.files : []
817
+ const renderedPatches = files.map(applyPatchFileIndexText).filter(Boolean).join("\n")
818
+ const patchText = stringValue(input?.patchText)
819
+ return [renderedPatches, patchText].filter(Boolean).join("\n")
820
+ }
821
+
822
+ if (tool === "edit") {
823
+ const filediff = recordValue(metadata?.filediff)
824
+ return [
825
+ stringValue(input?.filePath),
826
+ stringValue(metadata?.diff),
827
+ stringValue(filediff?.patch),
828
+ stringValue(input?.oldString),
829
+ stringValue(input?.newString),
830
+ ].filter(Boolean).join("\n")
831
+ }
832
+
833
+ if (tool === "write") {
834
+ return [stringValue(input?.filePath), stringValue(input?.content)].filter(Boolean).join("\n")
835
+ }
836
+
837
+ return ""
838
+ }
839
+
840
+ function applyPatchFileIndexText(value: unknown) {
841
+ const file = recordValue(value)
842
+ if (!file) return ""
843
+ return [
844
+ stringValue(file.filePath),
845
+ stringValue(file.relativePath),
846
+ stringValue(file.patch),
847
+ ].filter(Boolean).join("\n")
848
+ }
849
+
850
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
851
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
852
+ return value as Record<string, unknown>
853
+ }
854
+
855
+ function stringValue(value: unknown) {
856
+ return typeof value === "string" ? value : undefined
857
+ }
858
+
776
859
  const tableCache = new Map<string, boolean>()
777
860
  function tableExists(db: Database, name: string) {
778
861
  if (tableCache.has(name)) return tableCache.get(name)!
@@ -816,29 +899,6 @@ function extractFromValue(value: unknown): string {
816
899
  .join("\n")
817
900
  }
818
901
 
819
- function sourceRowToSearchRow(row: SourceRow): Row[] {
820
- const text = extractSearchText(row.data)
821
- if (!text) return []
822
- return [{ ...row, text }]
823
- }
824
-
825
- function searchablePartText(value: unknown): string {
826
- if (!value || typeof value !== "object" || Array.isArray(value)) return extractFromValue(value)
827
- const record = value as Record<string, unknown>
828
- const type = record.type
829
-
830
- if (type === "text" || type === "reasoning") return typeof record.text === "string" ? record.text : extractFromValue(record)
831
- if (type === "patch") return extractFromValue(record.files)
832
-
833
- if (type === "tool") {
834
- const state = record.state && typeof record.state === "object" && !Array.isArray(record.state) ? record.state as Record<string, unknown> : undefined
835
- if (record.tool !== "apply_patch") return ""
836
- return extractFromValue(state?.input)
837
- }
838
-
839
- return extractFromValue(record)
840
- }
841
-
842
902
  function truncate(value: string, length: number) {
843
903
  if (value.length <= length) return value
844
904
  return `${value.slice(0, length - 3)}...`
package/telescope.tsx CHANGED
@@ -46,14 +46,14 @@ 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 [previewEdgeLoadingReady, setPreviewEdgeLoadingReady] = createSignal(false)
50
- const RESULT_BATCH_SIZE = 50
51
- const RESULT_PREFETCH_AHEAD_ROWS = 25
49
+ const MIN_SEARCH_BATCH_SIZE = 25
50
+ const MIN_RECENT_BATCH_SIZE = 15
52
51
  const RESULT_OVERSCAN_MULTIPLIER = 2
52
+ const RESULT_BATCH_VIEWPORTS = 4
53
+ const RESULT_PREFETCH_VIEWPORTS = 10
53
54
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
54
- const INITIAL_PREVIEW_BEFORE = 6
55
- const INITIAL_PREVIEW_AFTER = 12
56
- const INITIAL_PREVIEW_DELAY_MS = 75
55
+ const INITIAL_PREVIEW_BEFORE = 20
56
+ const INITIAL_PREVIEW_AFTER = 30
57
57
  const PREVIEW_PAGE_SIZE = 20
58
58
  const PREVIEW_PREFETCH_VIEWPORTS = 0.5
59
59
  let input: InputRenderable | undefined
@@ -91,19 +91,18 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
91
91
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
92
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
93
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
- let pendingPreviewBefore: { itemId: string; previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
- let pendingPreviewAfter: { itemId: string; visibleLoad: boolean } | undefined
94
+ let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
+ let pendingPreviewAfterVisible = false
96
96
  const cancelPreviewPrefetch = () => {
97
97
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
98
  if (previewAfterTimer) clearTimeout(previewAfterTimer)
99
99
  previewBeforeTimer = undefined
100
100
  previewAfterTimer = undefined
101
101
  pendingPreviewBefore = undefined
102
- pendingPreviewAfter = undefined
102
+ pendingPreviewAfterVisible = false
103
103
  setPrefetchingPreviewBefore(false)
104
104
  setPrefetchingPreviewAfter(false)
105
105
  setLoadingPreviewMore(false)
106
- setPreviewEdgeLoadingReady(false)
107
106
  }
108
107
  onCleanup(() => {
109
108
  if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
@@ -116,9 +115,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
116
115
  const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
117
116
  return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
118
117
  }
119
- const searchBatchSize = () => RESULT_BATCH_SIZE
120
- const recentBatchSize = () => RESULT_BATCH_SIZE
121
- const resultPrefetchThreshold = () => RESULT_PREFETCH_AHEAD_ROWS
118
+ const searchBatchSize = () => Math.max(MIN_SEARCH_BATCH_SIZE, visibleResultRows() * RESULT_BATCH_VIEWPORTS)
119
+ const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() * RESULT_BATCH_VIEWPORTS)
120
+ const resultPrefetchThreshold = () => visibleResultRows() * RESULT_PREFETCH_VIEWPORTS
122
121
  const resultPrefetchState = () => {
123
122
  const cachedEnd = resultBaseOffset() + results().length
124
123
  const rowsAhead = Math.max(0, cachedEnd - selected() - 1)
@@ -438,20 +437,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
438
437
  return lastChild ? lastChild.y + lastChild.height : 0
439
438
  }
440
439
 
441
- const resetPreviewScroll = () => {
442
- if (!previewScroll || previewScroll.y === 0) return
443
- previewScroll.scrollBy(-previewScroll.y)
444
- }
445
-
446
- const loadPreviewBefore = (itemId: string, previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
440
+ const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
447
441
  const item = selectedResult()
448
442
  const first = previewParts()[0]
449
- if (!item || item.id !== itemId || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
443
+ if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
450
444
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
451
445
  debug.time("preview:load-before")
452
446
  try {
453
447
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
454
- if (selectedResult()?.id !== itemId) return
455
448
  debug.log("preview:load-before", {
456
449
  item: item.id,
457
450
  added: page.parts.length,
@@ -465,7 +458,6 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
465
458
  setPreviewParts((prev) => [...page.parts, ...prev])
466
459
  if (preserveScroll) {
467
460
  setTimeout(() => {
468
- if (selectedResult()?.id !== itemId) return
469
461
  const delta = previewContentHeight() - previousContentHeight
470
462
  if (delta > 0) previewScroll?.scrollBy(delta)
471
463
  }, 1)
@@ -480,15 +472,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
480
472
  }
481
473
  }
482
474
 
483
- const loadPreviewAfter = (itemId: string, visibleLoad = false) => {
475
+ const loadPreviewAfter = (visibleLoad = false) => {
484
476
  const item = selectedResult()
485
477
  const last = previewParts().at(-1)
486
- if (!item || item.id !== itemId || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
478
+ if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
487
479
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
488
480
  debug.time("preview:load-after")
489
481
  try {
490
482
  const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
491
- if (selectedResult()?.id !== itemId) return
492
483
  debug.log("preview:load-after", {
493
484
  item: item.id,
494
485
  added: page.parts.length,
@@ -508,8 +499,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
508
499
  }
509
500
 
510
501
  const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
511
- const item = selectedResult()
512
- if (!item || !hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
502
+ if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
513
503
  if (previewBeforeTimer) {
514
504
  if (pendingPreviewBefore) {
515
505
  pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
@@ -517,27 +507,26 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
517
507
  }
518
508
  return
519
509
  }
520
- pendingPreviewBefore = { itemId: item.id, previousContentHeight, preserveScroll, visibleLoad }
521
- debug.log("preview:prefetch-before-scheduled", { item: item.id, preserveScroll, visibleLoad })
510
+ pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
+ debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
522
512
  previewBeforeTimer = setTimeout(() => {
523
513
  const pending = pendingPreviewBefore
524
514
  previewBeforeTimer = undefined
525
515
  pendingPreviewBefore = undefined
526
- if (pending && selectedResult()?.id === pending.itemId) loadPreviewBefore(pending.itemId, pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
516
+ if (pending) loadPreviewBefore(pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
527
517
  }, 1)
528
518
  }
529
519
 
530
520
  const schedulePreviewAfter = (visibleLoad = false) => {
531
- const item = selectedResult()
532
- if (!item || !hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
533
- pendingPreviewAfter = { itemId: item.id, visibleLoad: (pendingPreviewAfter?.visibleLoad || visibleLoad) }
521
+ if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
522
+ pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
534
523
  if (previewAfterTimer) return
535
- debug.log("preview:prefetch-after-scheduled", { item: item.id, visibleLoad })
524
+ debug.log("preview:prefetch-after-scheduled", { visibleLoad })
536
525
  previewAfterTimer = setTimeout(() => {
537
- const pending = pendingPreviewAfter
526
+ const pendingVisibleLoad = pendingPreviewAfterVisible
538
527
  previewAfterTimer = undefined
539
- pendingPreviewAfter = undefined
540
- if (pending && selectedResult()?.id === pending.itemId) loadPreviewAfter(pending.itemId, pending.visibleLoad)
528
+ pendingPreviewAfterVisible = false
529
+ loadPreviewAfter(pendingVisibleLoad)
541
530
  }, 1)
542
531
  }
543
532
 
@@ -554,62 +543,33 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
554
543
  if (item.id === lastPreviewItemId) return
555
544
  lastPreviewItemId = item.id
556
545
  cancelPreviewPrefetch()
557
- resetPreviewScroll()
558
- solidBatch(() => {
559
- setPreviewParts([])
560
- setHasMorePreviewBefore(false)
561
- setHasMorePreviewAfter(false)
562
- setPreviewEdgeLoadingReady(false)
563
- })
564
546
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
565
547
  const db = dbPath()
566
- const itemId = item.id
567
- debug.log("preview:schedule", { item: item.id, delayMs: INITIAL_PREVIEW_DELAY_MS, before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
548
+ debug.time("preview:load")
549
+ try {
550
+ const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
551
+ debug.log("preview:init", {
552
+ item: item.id,
553
+ session: item.sessionID,
554
+ parts: page.parts.length,
555
+ hasMoreBefore: page.hasMoreBefore,
556
+ hasMoreAfter: page.hasMoreAfter,
557
+ first: page.parts[0]?.id,
558
+ last: page.parts.at(-1)?.id,
559
+ })
560
+ setPreviewParts(page.parts)
561
+ setHasMorePreviewBefore(page.hasMoreBefore)
562
+ setHasMorePreviewAfter(page.hasMoreAfter)
563
+ } catch {}
568
564
  debug.timeEnd("nav:total")
569
- const timer = setTimeout(() => {
570
- if (selectedResult()?.id !== itemId) {
571
- debug.log("preview:load:cancelled", { item: itemId })
572
- return
573
- }
574
- debug.time("preview:load")
575
- try {
576
- const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
577
- if (selectedResult()?.id !== itemId) {
578
- debug.log("preview:load:cancelled", { item: itemId, afterLoad: true })
579
- return
580
- }
581
- debug.log("preview:init", {
582
- item: item.id,
583
- session: item.sessionID,
584
- parts: page.parts.length,
585
- hasMoreBefore: page.hasMoreBefore,
586
- hasMoreAfter: page.hasMoreAfter,
587
- first: page.parts[0]?.id,
588
- last: page.parts.at(-1)?.id,
589
- })
590
- solidBatch(() => {
591
- setPreviewParts(page.parts)
592
- setHasMorePreviewBefore(page.hasMoreBefore)
593
- setHasMorePreviewAfter(page.hasMoreAfter)
594
- })
595
- } catch {
596
- } finally {
597
- debug.timeEnd("preview:load")
598
- }
599
- }, INITIAL_PREVIEW_DELAY_MS)
600
- onCleanup(() => {
601
- clearTimeout(timer)
602
- if (selectedResult()?.id !== itemId) {
603
- debug.log("preview:load:cancelled", { item: itemId, cleanup: true })
604
- }
605
- })
565
+ debug.timeEnd("preview:load")
606
566
  })
607
567
 
608
568
  createEffect(() => {
609
569
  const item = selectedResult()
610
570
  if (!item) return
611
571
  const interval = setInterval(() => {
612
- if (!previewEdgeLoadingReady() || loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
572
+ if (loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
613
573
  const scroll = previewScroll
614
574
  const children = scroll?.getChildren()
615
575
  if (!scroll || !children || children.length === 0) return
@@ -650,19 +610,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
650
610
  if (!item) return
651
611
  if (item.id === scrolledItem) return
652
612
  scrolledItem = item.id
653
- const itemId = item.id
654
- let readyTimer: ReturnType<typeof setTimeout> | undefined
655
- const timer = setTimeout(() => {
656
- if (selectedResult()?.id !== itemId) return
657
- scrollPreviewToTarget(previewScroll, messageTargetID(item))
658
- readyTimer = setTimeout(() => {
659
- if (selectedResult()?.id === itemId) setPreviewEdgeLoadingReady(true)
660
- }, 100)
661
- }, 1)
662
- onCleanup(() => {
663
- clearTimeout(timer)
664
- if (readyTimer) clearTimeout(readyTimer)
665
- })
613
+ const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
614
+ onCleanup(() => clearTimeout(timer))
666
615
  })
667
616
 
668
617
  const open = () => {
@@ -6,7 +6,7 @@ export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
6
6
  }
7
7
 
8
8
  export function messageTargetID(item: SearchResult) {
9
- if (item.partType === "tool") return `tool-inline-${item.messageID}-${item.id}`
9
+ if (item.partType === "tool") return `tool-${item.messageID}-${item.id}`
10
10
  if (item.partType === "reasoning") return `text-${item.messageID}-${item.id}`
11
11
  if (item.role === "assistant") return `text-${item.messageID}-${item.id}`
12
12
  return item.messageID