@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.
- package/components/preview.tsx +258 -13
- package/package.json +1 -1
- package/search.ts +155 -12
- package/telescope.tsx +143 -17
- package/ui/render-target.ts +31 -3
package/components/preview.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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())
|
|
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())
|
|
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())
|
|
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())
|
|
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)
|
|
524
|
-
|
|
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
|
|
577
|
-
const
|
|
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.
|
|
581
|
-
const atBottom = scroll.
|
|
582
|
-
const nearBottom = scroll.
|
|
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,
|
|
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
|
-
|
|
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 = () => {
|
package/ui/render-target.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
17
|
-
|
|
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) {
|