@bojackduy/opencode-telescope 0.1.13 → 0.1.14

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/README.md CHANGED
@@ -42,9 +42,47 @@ Add the plugin to your `tui.json`:
42
42
  | ---------------- | ----------------------------------------------- |
43
43
  | Open search | `<leader>f` or `/telescope` |
44
44
  | Type to filter | Fuzzy match against conversation text |
45
- | Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
45
+ | Navigate results | `↑` / `↓` or `j` / `k` |
46
46
  | Preview | Select a result to see the conversation preview |
47
47
  | Open | Press `Enter` to jump to the selected session |
48
+ | Owner filter | Press `o` to cycle `all` / `you` / `assistant` |
49
+
50
+ ## Configuration
51
+
52
+ Telescope reads optional plugin-specific config from:
53
+
54
+ ```txt
55
+ ~/.config/opencode/opencode-telescope/config.json
56
+ ```
57
+
58
+ If `$XDG_CONFIG_HOME` is set, the path is:
59
+
60
+ ```txt
61
+ $XDG_CONFIG_HOME/opencode/opencode-telescope/config.json
62
+ ```
63
+
64
+ Missing config, invalid JSON, and invalid individual fields are ignored. Defaults are kept for anything not configured.
65
+
66
+ Example:
67
+
68
+ ```jsonc
69
+ {
70
+ "openKey": "<leader>f",
71
+ "keys": {
72
+ "moveDown": ["down", "j"],
73
+ "moveUp": ["up", "k"],
74
+ "scrollPreviewDown": ["d"],
75
+ "scrollPreviewUp": ["u"],
76
+ "open": ["enter", "return"],
77
+ "close": ["q", "escape"],
78
+ "insertMode": ["/"],
79
+ "normalMode": ["ctrl+q"],
80
+ "toggleOwner": ["o"]
81
+ }
82
+ }
83
+ ```
84
+
85
+ Key strings support simple names like `j`, `k`, `down`, `up`, `enter`, `return`, and `escape`, plus modifier strings like `ctrl+q`.
48
86
 
49
87
  ## How it works
50
88
 
@@ -54,9 +54,9 @@ export const ResultRow = (props: {
54
54
  </box>
55
55
  )
56
56
 
57
- export const EmptyState = (props: { query: string; theme: TuiThemeCurrent }) => (
57
+ export const EmptyState = (props: { query: string; owner: string; theme: TuiThemeCurrent }) => (
58
58
  <box paddingLeft={1} paddingTop={1}>
59
- <text fg={props.theme.textMuted}>{props.query.trim() ? "No matching user/assistant conversation text." : "No recent conversation text found."}</text>
59
+ <text fg={props.theme.textMuted}>{props.query.trim() ? `No matching ${props.owner} conversation text.` : `No recent ${props.owner} conversation text found.`}</text>
60
60
  </box>
61
61
  )
62
62
 
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.13",
4
+ "version": "0.1.14",
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
@@ -27,6 +27,8 @@ export type SearchResult = {
27
27
  text: string
28
28
  }
29
29
 
30
+ export type SearchRole = "user" | "assistant"
31
+
30
32
  export type ConversationPreviewPart = {
31
33
  id: string
32
34
  messageID: string
@@ -64,7 +66,7 @@ type Row = {
64
66
  session_id: string
65
67
  session_title: string | null
66
68
  directory: string
67
- role: "user" | "assistant"
69
+ role: SearchRole
68
70
  time_created: number
69
71
  text: string
70
72
  }
@@ -73,7 +75,7 @@ type ConversationRow = {
73
75
  id: string
74
76
  message_id: string
75
77
  session_id: string
76
- role: "user" | "assistant"
78
+ role: SearchRole
77
79
  type: "text" | "reasoning" | "tool"
78
80
  time_created: number
79
81
  data: string
@@ -127,18 +129,18 @@ export function resolveDatabasePath() {
127
129
  }
128
130
  }
129
131
 
130
- export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
132
+ export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
131
133
  const term = query.trim()
132
134
  if (!term) return []
133
135
  if (options?.dbPath === ":memory:") return []
134
136
  const dbPath = options?.dbPath ?? resolveDatabasePath()
135
137
  const db = getDb(dbPath)
136
- return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset)
138
+ return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset, options?.role)
137
139
  }
138
140
 
139
- export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
141
+ export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
140
142
  const db = getDb(options?.dbPath)
141
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
143
+ return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset, options?.role).flatMap(
142
144
  (row) => rowToSearchResult(row, "") ?? [],
143
145
  )
144
146
  }
@@ -535,10 +537,10 @@ function parseToolState(value: unknown): ToolState | undefined {
535
537
  }
536
538
  }
537
539
 
538
- function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number) {
540
+ function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
539
541
  if (!tableExists(db, "part") || !tableExists(db, "message")) return []
540
542
  debug.time("query:sql")
541
- const rows = indexedTextRows(db, dbPath, limit, query, directory, offset) ?? visibleTextRows(db, limit, query, directory, offset)
543
+ const rows = indexedTextRows(db, dbPath, limit, query, directory, offset, role) ?? visibleTextRows(db, limit, query, directory, offset, role)
542
544
  debug.timeEnd("query:sql")
543
545
  debug.time("query:map")
544
546
  const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
@@ -546,13 +548,17 @@ function searchRows(db: Database, dbPath: string, query: string, limit: number,
546
548
  return results
547
549
  }
548
550
 
549
- function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number) {
551
+ function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number, role?: SearchRole) {
550
552
  const match = ftsQuery(query)
551
553
  if (!match) return []
552
554
  try {
553
555
  const index = ensureSearchIndex(db, dbPath)
554
556
  const conditions = ["document_fts MATCH ?"]
555
557
  const params: (string | number)[] = [match]
558
+ if (role) {
559
+ conditions.push("role = ?")
560
+ params.push(role)
561
+ }
556
562
  if (directory) {
557
563
  conditions.push("directory = ?")
558
564
  params.push(directory)
@@ -577,14 +583,16 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
577
583
  }
578
584
  }
579
585
 
580
- function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
586
+ function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
581
587
  const offsetClause = offset ? "OFFSET ?" : ""
582
588
  const conditions: string[] = [
583
589
  "json_extract(p.data, '$.type') = 'text'",
584
- "json_extract(m.data, '$.role') IN ('user', 'assistant')",
590
+ role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
585
591
  ]
586
592
  const params: (string | number)[] = []
587
593
 
594
+ if (role) params.push(role)
595
+
588
596
  if (directory) {
589
597
  conditions.push("s.directory = ?")
590
598
  params.push(directory)
package/telescope.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
3
3
  import type { InputRenderable, ParsedKey, ScrollBoxRenderable } from "@opentui/core"
4
4
  import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
5
- import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
5
+ import { For, Show, batch as solidBatch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
6
6
  import { ConversationPreview, PreviewHeader } from "./components/preview.tsx"
7
7
  import { EmptyState, ResultRow, SkeletonRow } from "./components/result-list.tsx"
8
8
  import {
@@ -14,43 +14,71 @@ import {
14
14
  searchSessionMessages,
15
15
  type ConversationPreviewPart,
16
16
  type SearchResult,
17
+ type SearchRole,
17
18
  } from "./search.ts"
18
19
  import { debug } from "./ui/debug.ts"
19
20
  import { syntaxStyle } from "./ui/format.ts"
20
- import { isKey, prevent } from "./ui/keyboard.ts"
21
+ import type { TelescopeConfig } from "./ui/config.ts"
22
+ import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.ts"
21
23
  import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
22
24
 
23
- export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) => {
25
+ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
26
+ type OwnerFilter = "all" | SearchRole
24
27
  const dimensions = useTerminalDimensions()
25
28
  const [query, setQuery] = createSignal("")
29
+ const [ownerFilter, setOwnerFilter] = createSignal<OwnerFilter>("all")
26
30
  const [results, setResults] = createSignal<SearchResult[]>([])
27
31
  const [previewParts, setPreviewParts] = createSignal<ConversationPreviewPart[]>([])
28
32
  const [selected, setSelected] = createSignal(0)
33
+ const [resultBaseOffset, setResultBaseOffset] = createSignal(0)
34
+ const [nextResultOffset, setNextResultOffset] = createSignal(0)
29
35
  const [busy, setBusy] = createSignal(false)
30
36
  const [error, setError] = createSignal("")
31
37
  const [mode, setMode] = createSignal<"normal" | "insert">("normal")
32
38
  const [loading, setLoading] = createSignal(true)
33
39
  const [hasMore, setHasMore] = createSignal(true)
34
40
  const [loadingMore, setLoadingMore] = createSignal(false)
41
+ const [loadingPreviousResults, setLoadingPreviousResults] = createSignal(false)
35
42
  const [prefetchingResults, setPrefetchingResults] = createSignal(false)
36
43
  const [resultPageInfo, setResultPageInfo] = createSignal({ loadedUntil: 0, hasMore: true, pageSize: 0, lastOffset: 0, lastAdded: 0 })
37
44
  const [hasMorePreviewBefore, setHasMorePreviewBefore] = createSignal(false)
38
45
  const [hasMorePreviewAfter, setHasMorePreviewAfter] = createSignal(false)
39
46
  const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
47
+ const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
+ const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
40
49
  const MIN_SEARCH_BATCH_SIZE = 25
41
50
  const MIN_RECENT_BATCH_SIZE = 15
42
51
  const RESULT_OVERSCAN_MULTIPLIER = 2
43
- const RESULT_PREFETCH_VIEWPORTS = 3
52
+ const RESULT_BATCH_VIEWPORTS = 4
53
+ const RESULT_PREFETCH_VIEWPORTS = 10
54
+ const RESULT_CACHE_BEHIND_VIEWPORTS = 6
44
55
  const INITIAL_PREVIEW_BEFORE = 20
45
56
  const INITIAL_PREVIEW_AFTER = 30
46
57
  const PREVIEW_PAGE_SIZE = 20
58
+ const PREVIEW_PREFETCH_VIEWPORTS = 0.5
47
59
  let input: InputRenderable | undefined
48
60
  let resultScroll: ScrollBoxRenderable | undefined
49
61
  let previewScroll: ScrollBoxRenderable | undefined
50
62
 
51
63
  const theme = createMemo(() => props.api.theme.current)
52
64
  const syntax = createMemo(() => syntaxStyle(theme()))
53
- const selectedResult = createMemo(() => results()[selected()])
65
+ const ownerRole = createMemo(() => ownerFilter() === "all" ? undefined : ownerFilter() as SearchRole)
66
+ const ownerLabel = createMemo(() => ownerFilter() === "user" ? "you" : ownerFilter())
67
+ const inputKeys = createMemo(() => ({
68
+ moveDown: inputSafeKeys(props.config.keys.moveDown),
69
+ moveUp: inputSafeKeys(props.config.keys.moveUp),
70
+ open: inputSafeKeys(props.config.keys.open),
71
+ normalMode: inputSafeKeys(props.config.keys.normalMode),
72
+ }))
73
+ const normalHelpItems = createMemo(() => [
74
+ `${keyListLabel(props.config.keys.moveUp)}/${keyListLabel(props.config.keys.moveDown)} move`,
75
+ `${keyListLabel(props.config.keys.scrollPreviewDown)}/${keyListLabel(props.config.keys.scrollPreviewUp)} scroll`,
76
+ `${keyListLabel(props.config.keys.toggleOwner)} owner`,
77
+ `${keyListLabel(props.config.keys.insertMode)} search`,
78
+ `${keyListLabel(props.config.keys.open)} open`,
79
+ `${keyListLabel(props.config.keys.close)} close`,
80
+ ])
81
+ const selectedResult = createMemo(() => results()[selected() - resultBaseOffset()])
54
82
  const popupWidth = createMemo(() => Math.max(72, Math.min(dimensions().width - 2, Math.floor(dimensions().width * 0.92))))
55
83
  const leftWidth = createMemo(() => Math.max(36, Math.min(64, Math.floor(popupWidth() * 0.36))))
56
84
  const height = createMemo(() => Math.max(18, dimensions().height - 8))
@@ -58,9 +86,28 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
58
86
  const dbPath = createMemo(() => resolveDatabasePath())
59
87
  const directory = props.api.state.path.directory
60
88
  let advanceSelectionAfterLoad = false
89
+ let advanceSelectionBeforeLoad = false
61
90
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
91
+ let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
+ let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
+ let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
+ let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
+ let pendingPreviewAfterVisible = false
96
+ const cancelPreviewPrefetch = () => {
97
+ if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
+ if (previewAfterTimer) clearTimeout(previewAfterTimer)
99
+ previewBeforeTimer = undefined
100
+ previewAfterTimer = undefined
101
+ pendingPreviewBefore = undefined
102
+ pendingPreviewAfterVisible = false
103
+ setPrefetchingPreviewBefore(false)
104
+ setPrefetchingPreviewAfter(false)
105
+ setLoadingPreviewMore(false)
106
+ }
62
107
  onCleanup(() => {
63
108
  if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
109
+ if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
110
+ cancelPreviewPrefetch()
64
111
  })
65
112
 
66
113
  const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
@@ -68,27 +115,47 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
68
115
  const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
69
116
  return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
70
117
  }
71
- const searchBatchSize = () => Math.max(MIN_SEARCH_BATCH_SIZE, visibleResultRows() * 2)
72
- const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() * 2)
73
- const resultPrefetchThreshold = () => Math.max(visibleResultRows() * RESULT_PREFETCH_VIEWPORTS, searchBatchSize())
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
121
+ const resultPrefetchState = () => {
122
+ const cachedEnd = resultBaseOffset() + results().length
123
+ const rowsAhead = Math.max(0, cachedEnd - selected() - 1)
124
+ const threshold = resultPrefetchThreshold()
125
+ return { cachedEnd, rowsAhead, threshold, shouldPrefetch: rowsAhead <= threshold }
126
+ }
127
+ const trimResultCache = (items: SearchResult[], anchorIndex: number) => {
128
+ const base = resultBaseOffset()
129
+ const keepBehind = visibleResultRows() * RESULT_CACHE_BEHIND_VIEWPORTS
130
+ const minOffset = Math.max(0, anchorIndex - keepBehind)
131
+ const drop = Math.min(Math.max(0, minOffset - base), Math.max(0, items.length - 1))
132
+ if (drop === 0) return { base, items }
133
+
134
+ const nextBase = base + drop
135
+ debug.log("results:evict", { fromBase: base, toBase: nextBase, dropped: drop, kept: items.length - drop, anchorIndex })
136
+ return { base: nextBase, items: items.slice(drop) }
137
+ }
74
138
  const resultRenderWindow = createMemo(() => {
75
- const total = results().length
139
+ const base = resultBaseOffset()
140
+ const cached = results()
76
141
  const visible = visibleResultRows()
77
142
  const overscan = visible * RESULT_OVERSCAN_MULTIPLIER
78
143
  const index = selected()
79
- const start = Math.max(0, index - overscan)
80
- const end = Math.min(total, index + visible + overscan)
81
- return { start, end, items: results().slice(start, end) }
144
+ const cachedStart = Math.max(0, index - overscan - base)
145
+ const cachedEnd = Math.min(cached.length, index + visible + overscan - base)
146
+ return { start: base + cachedStart, end: base + cachedEnd, items: cached.slice(cachedStart, cachedEnd) }
82
147
  })
83
148
 
84
149
  let lastResultRenderWindow = ""
85
150
  createEffect(() => {
86
151
  const window = resultRenderWindow()
87
- const key = `${window.start}:${window.end}:${results().length}`
152
+ const key = `${window.start}:${window.end}:${resultBaseOffset()}:${nextResultOffset()}:${results().length}`
88
153
  if (key === lastResultRenderWindow) return
89
154
  lastResultRenderWindow = key
90
155
  debug.log("results:render-window", {
91
156
  selected: selected(),
157
+ baseOffset: resultBaseOffset(),
158
+ nextOffset: nextResultOffset(),
92
159
  totalLoaded: results().length,
93
160
  start: window.start,
94
161
  end: window.end,
@@ -101,8 +168,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
101
168
 
102
169
  createEffect(() => {
103
170
  const q = query().trim()
171
+ const role = ownerRole()
104
172
  setError("")
105
173
  setHasMore(true)
174
+ if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
175
+ if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
176
+ resultPrefetchTimer = undefined
177
+ resultPreviousTimer = undefined
178
+ advanceSelectionAfterLoad = false
179
+ advanceSelectionBeforeLoad = false
180
+ setLoadingMore(false)
181
+ setLoadingPreviousResults(false)
182
+ setPrefetchingResults(false)
106
183
  const db = dbPath()
107
184
  const dir = directory
108
185
 
@@ -112,14 +189,22 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
112
189
  const timer = setTimeout(() => {
113
190
  debug.time("query:recent")
114
191
  try {
115
- const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir })
116
- setResults(batch)
117
- setHasMore(batch.length >= limit)
118
- setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
119
- setSelected(0)
192
+ const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
193
+ solidBatch(() => {
194
+ setResults(batch)
195
+ setResultBaseOffset(0)
196
+ setNextResultOffset(batch.length)
197
+ setHasMore(batch.length >= limit)
198
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
199
+ setSelected(0)
200
+ })
120
201
  } catch (err) {
121
- setResults([])
122
- setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
202
+ solidBatch(() => {
203
+ setResults([])
204
+ setResultBaseOffset(0)
205
+ setNextResultOffset(0)
206
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
207
+ })
123
208
  setError(err instanceof Error ? err.message : String(err))
124
209
  }
125
210
  debug.timeEnd("query:recent")
@@ -135,14 +220,22 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
135
220
  const timer = setTimeout(() => {
136
221
  debug.time("query:search")
137
222
  try {
138
- const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir })
139
- setResults(batch)
140
- setHasMore(batch.length >= limit)
141
- setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
142
- setSelected(0)
223
+ const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
224
+ solidBatch(() => {
225
+ setResults(batch)
226
+ setResultBaseOffset(0)
227
+ setNextResultOffset(batch.length)
228
+ setHasMore(batch.length >= limit)
229
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
230
+ setSelected(0)
231
+ })
143
232
  } catch (err) {
144
- setResults([])
145
- setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
233
+ solidBatch(() => {
234
+ setResults([])
235
+ setResultBaseOffset(0)
236
+ setNextResultOffset(0)
237
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
238
+ })
146
239
  setError(err instanceof Error ? err.message : String(err))
147
240
  } finally {
148
241
  debug.timeEnd("query:search")
@@ -159,10 +252,11 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
159
252
  advanceSelectionAfterLoad = false
160
253
  return
161
254
  }
162
- if (loadingMore() || prefetchingResults() || busy() || loading()) return
255
+ if (loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
163
256
 
164
- const total = results().length
257
+ const offset = nextResultOffset()
165
258
  const q = query().trim()
259
+ const role = ownerRole()
166
260
  const db = dbPath()
167
261
  const dir = directory
168
262
  const limit = q ? searchBatchSize() : recentBatchSize()
@@ -171,18 +265,35 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
171
265
  debug.time("query:load-more")
172
266
  try {
173
267
  const batch = q
174
- ? searchSessionMessages(q, { limit, offset: total, dbPath: db, directory: dir })
175
- : recentSessionMessages({ limit, offset: total, dbPath: db, directory: dir })
268
+ ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
269
+ : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
176
270
  const nextHasMore = batch.length >= limit
177
- const nextLoadedUntil = total + batch.length
178
- debug.log("results:prefetch", { offset: total, limit, added: batch.length, totalLoaded: nextLoadedUntil, hasMore: nextHasMore, advance })
179
- setResults((prev) => [...prev, ...batch])
180
- setResultPageInfo({ loadedUntil: nextLoadedUntil, hasMore: nextHasMore, pageSize: limit, lastOffset: total, lastAdded: batch.length })
181
- if (!nextHasMore) setHasMore(false)
182
- if (advanceSelectionAfterLoad) {
183
- debug.log("results:advance-after-load", { from: selected(), total, added: batch.length })
271
+ const nextLoadedUntil = offset + batch.length
272
+ const previousSelected = selected()
273
+ const nextSelected = advanceSelectionAfterLoad && batch.length > 0 ? offset : selected()
274
+ const nextCache = trimResultCache([...results(), ...batch], nextSelected)
275
+ debug.log("results:prefetch", {
276
+ offset,
277
+ limit,
278
+ added: batch.length,
279
+ baseOffset: nextCache.base,
280
+ cached: nextCache.items.length,
281
+ totalLoaded: nextLoadedUntil,
282
+ hasMore: nextHasMore,
283
+ advance,
284
+ })
285
+ const shouldAdvance = advanceSelectionAfterLoad
286
+ solidBatch(() => {
287
+ setResultBaseOffset(nextCache.base)
288
+ setNextResultOffset(nextLoadedUntil)
289
+ setResults(nextCache.items)
290
+ setResultPageInfo({ loadedUntil: nextLoadedUntil, hasMore: nextHasMore, pageSize: limit, lastOffset: offset, lastAdded: batch.length })
291
+ if (!nextHasMore) setHasMore(false)
292
+ if (shouldAdvance && batch.length > 0) setSelected(offset)
293
+ })
294
+ if (shouldAdvance) {
295
+ debug.log("results:advance-after-load", { from: previousSelected, offset, added: batch.length })
184
296
  advanceSelectionAfterLoad = false
185
- if (batch.length > 0) setSelected(total)
186
297
  }
187
298
  } catch (err) {
188
299
  debug.log("results:load-more:error", err instanceof Error ? err.message : String(err))
@@ -193,9 +304,51 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
193
304
  }
194
305
  }
195
306
 
307
+ const loadPreviousResults = (advance = false) => {
308
+ if (advance) advanceSelectionBeforeLoad = true
309
+ const base = resultBaseOffset()
310
+ if (base <= 0) {
311
+ advanceSelectionBeforeLoad = false
312
+ return
313
+ }
314
+ if (loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
315
+
316
+ const q = query().trim()
317
+ const role = ownerRole()
318
+ const db = dbPath()
319
+ const dir = directory
320
+ const pageSize = q ? searchBatchSize() : recentBatchSize()
321
+ const offset = Math.max(0, base - pageSize)
322
+ const limit = base - offset
323
+
324
+ setLoadingPreviousResults(true)
325
+ debug.time("query:load-before")
326
+ try {
327
+ const batch = q
328
+ ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
329
+ : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
330
+ const nextSelected = advanceSelectionBeforeLoad && batch.length > 0 ? base - 1 : selected()
331
+ debug.log("results:load-before", { offset, limit, added: batch.length, fromBase: base, toBase: offset, cached: results().length + batch.length, advance })
332
+ const shouldAdvance = advanceSelectionBeforeLoad
333
+ solidBatch(() => {
334
+ setResultBaseOffset(offset)
335
+ setResults([...batch, ...results()])
336
+ setResultPageInfo({ loadedUntil: nextResultOffset(), hasMore: hasMore(), pageSize, lastOffset: offset, lastAdded: batch.length })
337
+ if (shouldAdvance && batch.length > 0) setSelected(nextSelected)
338
+ })
339
+ if (shouldAdvance) advanceSelectionBeforeLoad = false
340
+ } catch (err) {
341
+ debug.log("results:load-before:error", err instanceof Error ? err.message : String(err))
342
+ advanceSelectionBeforeLoad = false
343
+ } finally {
344
+ debug.timeEnd("query:load-before")
345
+ setLoadingPreviousResults(false)
346
+ }
347
+ }
348
+
196
349
  const scheduleResultPrefetch = (advance = false) => {
197
350
  if (advance) advanceSelectionAfterLoad = true
198
- if (resultPrefetchTimer || loadingMore() || prefetchingResults()) return
351
+ if (resultPrefetchTimer || loadingMore() || loadingPreviousResults() || prefetchingResults()) return
199
352
  debug.log("results:prefetch-scheduled", { advance, pendingAdvance: advanceSelectionAfterLoad, pageInfo: resultPageInfo() })
200
353
  resultPrefetchTimer = setTimeout(() => {
201
354
  resultPrefetchTimer = undefined
@@ -203,15 +356,33 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
203
356
  }, 1)
204
357
  }
205
358
 
359
+ const schedulePreviousResultsLoad = (advance = false) => {
360
+ if (advance) advanceSelectionBeforeLoad = true
361
+ if (resultPreviousTimer || loadingMore() || loadingPreviousResults() || prefetchingResults()) return
362
+ debug.log("results:load-before-scheduled", { advance, pendingAdvance: advanceSelectionBeforeLoad, baseOffset: resultBaseOffset(), pageInfo: resultPageInfo() })
363
+ resultPreviousTimer = setTimeout(() => {
364
+ resultPreviousTimer = undefined
365
+ loadPreviousResults(advanceSelectionBeforeLoad)
366
+ }, 1)
367
+ }
368
+
369
+ let lastResultPrefetchDecision = ""
370
+
206
371
  const move = (delta: number) => {
207
372
  if (results().length === 0) return
208
373
  setSelected((index) => {
374
+ const base = resultBaseOffset()
375
+ const cachedEnd = base + results().length
209
376
  const next = index + delta
210
377
  let finalIndex = next
211
- if (next < 0) finalIndex = results().length - 1
212
- else if (next >= results().length) {
378
+ if (next < 0) finalIndex = cachedEnd - 1
379
+ else if (next < base) {
380
+ schedulePreviousResultsLoad(true)
381
+ finalIndex = base
382
+ }
383
+ else if (next >= cachedEnd) {
213
384
  if (hasMore()) scheduleResultPrefetch(true)
214
- finalIndex = results().length - 1
385
+ finalIndex = cachedEnd - 1
215
386
  }
216
387
 
217
388
  if (finalIndex !== index) debug.time("nav:total")
@@ -235,11 +406,26 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
235
406
  })
236
407
 
237
408
  createEffect(() => {
238
- const index = selected()
239
- const total = results().length
240
- const threshold = resultPrefetchThreshold()
241
- if (index < total - threshold) return
242
- if (!hasMore() || loadingMore() || prefetchingResults() || busy() || loading()) return
409
+ const state = resultPrefetchState()
410
+ const blockedBy = !hasMore()
411
+ ? "no-more"
412
+ : loadingMore()
413
+ ? "loading-more"
414
+ : loadingPreviousResults()
415
+ ? "loading-before"
416
+ : prefetchingResults()
417
+ ? "prefetching"
418
+ : busy()
419
+ ? "busy"
420
+ : loading()
421
+ ? "loading"
422
+ : ""
423
+ const decisionKey = `${selected()}:${state.cachedEnd}:${state.rowsAhead}:${state.threshold}:${blockedBy}:${state.shouldPrefetch}`
424
+ if (decisionKey !== lastResultPrefetchDecision) {
425
+ lastResultPrefetchDecision = decisionKey
426
+ debug.log("results:prefetch-decision", { selected: selected(), ...state, blockedBy: blockedBy || undefined })
427
+ }
428
+ if (!state.shouldPrefetch || blockedBy) return
243
429
 
244
430
  const timer = setTimeout(() => scheduleResultPrefetch(false), 100)
245
431
  onCleanup(() => clearTimeout(timer))
@@ -251,11 +437,11 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
251
437
  return lastChild ? lastChild.y + lastChild.height : 0
252
438
  }
253
439
 
254
- const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true) => {
440
+ const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
255
441
  const item = selectedResult()
256
442
  const first = previewParts()[0]
257
- if (!item || !first || loadingPreviewMore()) return
258
- setLoadingPreviewMore(true)
443
+ if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
444
+ visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
259
445
  debug.time("preview:load-before")
260
446
  try {
261
447
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
@@ -264,6 +450,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
264
450
  added: page.parts.length,
265
451
  hasMoreBefore: page.hasMoreBefore,
266
452
  preserveScroll,
453
+ visibleLoad,
267
454
  first: page.parts[0]?.id,
268
455
  last: page.parts.at(-1)?.id,
269
456
  })
@@ -281,15 +468,15 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
281
468
  debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
282
469
  } finally {
283
470
  debug.timeEnd("preview:load-before")
284
- setLoadingPreviewMore(false)
471
+ visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
285
472
  }
286
473
  }
287
474
 
288
- const loadPreviewAfter = () => {
475
+ const loadPreviewAfter = (visibleLoad = false) => {
289
476
  const item = selectedResult()
290
477
  const last = previewParts().at(-1)
291
- if (!item || !last || loadingPreviewMore()) return
292
- setLoadingPreviewMore(true)
478
+ if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
479
+ visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
293
480
  debug.time("preview:load-after")
294
481
  try {
295
482
  const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
@@ -297,6 +484,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
297
484
  item: item.id,
298
485
  added: page.parts.length,
299
486
  hasMoreAfter: page.hasMoreAfter,
487
+ visibleLoad,
300
488
  first: page.parts[0]?.id,
301
489
  last: page.parts.at(-1)?.id,
302
490
  })
@@ -306,14 +494,47 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
306
494
  debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
307
495
  } finally {
308
496
  debug.timeEnd("preview:load-after")
309
- setLoadingPreviewMore(false)
497
+ visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewAfter(false)
310
498
  }
311
499
  }
312
500
 
501
+ const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
502
+ if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
503
+ if (previewBeforeTimer) {
504
+ if (pendingPreviewBefore) {
505
+ pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
506
+ pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
507
+ }
508
+ return
509
+ }
510
+ pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
+ debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
512
+ previewBeforeTimer = setTimeout(() => {
513
+ const pending = pendingPreviewBefore
514
+ previewBeforeTimer = undefined
515
+ pendingPreviewBefore = undefined
516
+ if (pending) loadPreviewBefore(pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
517
+ }, 1)
518
+ }
519
+
520
+ const schedulePreviewAfter = (visibleLoad = false) => {
521
+ if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
522
+ pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
523
+ if (previewAfterTimer) return
524
+ debug.log("preview:prefetch-after-scheduled", { visibleLoad })
525
+ previewAfterTimer = setTimeout(() => {
526
+ const pendingVisibleLoad = pendingPreviewAfterVisible
527
+ previewAfterTimer = undefined
528
+ pendingPreviewAfterVisible = false
529
+ loadPreviewAfter(pendingVisibleLoad)
530
+ }, 1)
531
+ }
532
+
313
533
  let lastPreviewItemId = ""
314
534
  createEffect(() => {
315
535
  const item = selectedResult()
316
536
  if (!item) {
537
+ cancelPreviewPrefetch()
317
538
  setPreviewParts([])
318
539
  setHasMorePreviewBefore(false)
319
540
  setHasMorePreviewAfter(false)
@@ -321,6 +542,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
321
542
  }
322
543
  if (item.id === lastPreviewItemId) return
323
544
  lastPreviewItemId = item.id
545
+ cancelPreviewPrefetch()
324
546
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
325
547
  const db = dbPath()
326
548
  debug.time("preview:load")
@@ -347,30 +569,36 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
347
569
  const item = selectedResult()
348
570
  if (!item) return
349
571
  const interval = setInterval(() => {
350
- if (loadingPreviewMore()) return
572
+ if (loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
351
573
  const scroll = previewScroll
352
574
  const children = scroll?.getChildren()
353
575
  if (!scroll || !children || children.length === 0) return
354
576
  const lastChild = children[children.length - 1] as { y: number; height: number }
355
577
  const totalContentHeight = lastChild.y + lastChild.height
356
578
  const atTop = scroll.y <= 0
357
- const nearTop = scroll.y <= 2
579
+ const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
580
+ const nearTop = scroll.y <= prefetchDistance
358
581
  const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
359
- if (nearTop || atBottom) {
582
+ const nearBottom = scroll.y + scroll.height >= totalContentHeight - prefetchDistance
583
+ if (nearTop || nearBottom) {
360
584
  debug.log("preview:scroll-edge", {
361
585
  y: scroll.y,
362
586
  height: scroll.height,
363
587
  contentHeight: totalContentHeight,
588
+ prefetchDistance,
364
589
  atTop,
365
590
  nearTop,
366
591
  atBottom,
592
+ nearBottom,
367
593
  hasMoreBefore: hasMorePreviewBefore(),
368
594
  hasMoreAfter: hasMorePreviewAfter(),
595
+ prefetchingBefore: prefetchingPreviewBefore(),
596
+ prefetchingAfter: prefetchingPreviewAfter(),
369
597
  children: children.length,
370
598
  })
371
599
  }
372
- if (nearTop && hasMorePreviewBefore()) loadPreviewBefore(totalContentHeight, !atTop)
373
- if (atBottom && hasMorePreviewAfter()) loadPreviewAfter()
600
+ if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, false, true)
601
+ if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
374
602
  }, 400)
375
603
  onCleanup(() => clearInterval(interval))
376
604
  })
@@ -409,50 +637,54 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
409
637
  el?.blur?.()
410
638
  }
411
639
 
640
+ const toggleOwnerFilter = () => {
641
+ setOwnerFilter((filter) => filter === "all" ? "user" : filter === "user" ? "assistant" : "all")
642
+ }
643
+
412
644
  useKeyboard((evt) => {
413
645
  if (!props.api.ui.dialog.open) return
414
646
 
415
- if (isKey(evt, "down") || isKey(evt, "up")) {
647
+ if (mode() !== "normal") return
648
+
649
+ if (matchesKey(evt, props.config.keys.moveDown) || matchesKey(evt, props.config.keys.moveUp)) {
416
650
  prevent(evt)
417
- isKey(evt, "down") ? move(1) : move(-1)
651
+ matchesKey(evt, props.config.keys.moveDown) ? move(1) : move(-1)
418
652
  return
419
653
  }
420
654
 
421
- if (mode() === "normal") {
422
- if (isKey(evt, "j")) {
423
- prevent(evt)
424
- move(1)
425
- return
426
- }
427
- if (isKey(evt, "k")) {
428
- prevent(evt)
429
- move(-1)
430
- return
431
- }
432
- if (isKey(evt, "q")) {
433
- prevent(evt)
434
- props.onClose()
435
- return
436
- }
437
- if (isKey(evt, "d")) {
438
- scrollPreview(1, evt)
439
- return
440
- }
441
- if (isKey(evt, "u")) {
442
- scrollPreview(-1, evt)
443
- return
444
- }
445
- if (isKey(evt, "/")) {
446
- prevent(evt)
447
- setMode("insert")
448
- focusInput()
449
- return
450
- }
451
- if (isKey(evt, "enter", "return")) {
452
- prevent(evt)
453
- open()
454
- return
455
- }
655
+ if (matchesKey(evt, props.config.keys.open)) {
656
+ prevent(evt)
657
+ open()
658
+ return
659
+ }
660
+
661
+ if (matchesKey(evt, props.config.keys.close)) {
662
+ prevent(evt)
663
+ props.onClose()
664
+ return
665
+ }
666
+
667
+ if (matchesKey(evt, props.config.keys.scrollPreviewDown)) {
668
+ scrollPreview(1, evt)
669
+ return
670
+ }
671
+
672
+ if (matchesKey(evt, props.config.keys.scrollPreviewUp)) {
673
+ scrollPreview(-1, evt)
674
+ return
675
+ }
676
+
677
+ if (matchesKey(evt, props.config.keys.toggleOwner)) {
678
+ prevent(evt)
679
+ toggleOwnerFilter()
680
+ return
681
+ }
682
+
683
+ if (matchesKey(evt, props.config.keys.insertMode)) {
684
+ prevent(evt)
685
+ setMode("insert")
686
+ focusInput()
687
+ return
456
688
  }
457
689
  })
458
690
 
@@ -483,12 +715,17 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
483
715
  focusedBackgroundColor={theme().backgroundPanel}
484
716
  onInput={(value) => setQuery(value)}
485
717
  onKeyDown={(evt: ParsedKey) => {
486
- if (isKey(evt, "down") || isKey(evt, "up")) {
718
+ if (matchesKey(evt, inputKeys().moveDown) || matchesKey(evt, inputKeys().moveUp)) {
719
+ prevent(evt)
720
+ matchesKey(evt, inputKeys().moveDown) ? move(1) : move(-1)
721
+ return
722
+ }
723
+ if (matchesKey(evt, inputKeys().open)) {
487
724
  prevent(evt)
488
- isKey(evt, "down") ? move(1) : move(-1)
725
+ open()
489
726
  return
490
727
  }
491
- if (evt.ctrl && isKey(evt, "q")) {
728
+ if (matchesKey(evt, inputKeys().normalMode)) {
492
729
  prevent(evt)
493
730
  setMode("normal")
494
731
  blurInput()
@@ -496,13 +733,13 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
496
733
  }}
497
734
  flexGrow={1}
498
735
  />
499
- <text fg={theme().textMuted}>{busy() ? "searching" : loading() ? "loading..." : query().trim() ? (results().length > 0 ? `${selected() + 1}/${results().length} hits` : "0 hits") : (results().length > 0 ? `${selected() + 1}/${results().length} recent` : "0 recent")}</text>
736
+ <text fg={theme().textMuted}>{busy() ? `searching ${ownerLabel()}` : loading() ? `loading ${ownerLabel()}` : query().trim() ? (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} hits` : `${ownerLabel()} 0 hits`) : (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} recent` : `${ownerLabel()} 0 recent`)}</text>
500
737
  </box>
501
738
  </box>
502
739
 
503
740
  <box flexDirection="row" flexGrow={1} minHeight={0}>
504
741
  <box width={leftWidth()} flexDirection="column" minHeight={0} backgroundColor={theme().backgroundPanel}>
505
- <scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: false }}>
742
+ <scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: true }}>
506
743
  <Show
507
744
  when={!error()}
508
745
  fallback={
@@ -513,7 +750,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
513
750
  }
514
751
  >
515
752
  <Show when={!loading()}>
516
- <Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
753
+ <Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} />}>
517
754
  <For each={resultRenderWindow().items}>
518
755
  {(item, index) => {
519
756
  const absoluteIndex = () => resultRenderWindow().start + index()
@@ -565,23 +802,21 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
565
802
  <Show when={mode() === "normal"}>
566
803
  <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
567
804
  <text fg={theme().accent}><span style={{ bold: true }}>NORMAL</span></text>
568
- <text fg={theme().textMuted}>·</text>
569
- <text fg={theme().text}>j/k move</text>
570
- <text fg={theme().textMuted}>·</text>
571
- <text fg={theme().textMuted}>d/u scroll</text>
572
- <text fg={theme().textMuted}>·</text>
573
- <text fg={theme().text}>/ search</text>
574
- <text fg={theme().textMuted}>·</text>
575
- <text fg={theme().textMuted}>enter open</text>
576
- <text fg={theme().textMuted}>·</text>
577
- <text fg={theme().text}>q close</text>
805
+ <For each={normalHelpItems()}>
806
+ {(item, index) => (
807
+ <>
808
+ <text fg={theme().textMuted}>·</text>
809
+ <text fg={index() % 2 === 0 ? theme().text : theme().textMuted}>{item}</text>
810
+ </>
811
+ )}
812
+ </For>
578
813
  </box>
579
814
  </Show>
580
815
  <Show when={mode() === "insert"}>
581
816
  <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
582
817
  <text fg={theme().warning}><span style={{ bold: true }}>INSERT</span></text>
583
818
  <text fg={theme().textMuted}>·</text>
584
- <text fg={theme().textMuted}>↑/↓ move · ^Q normal</text>
819
+ <text fg={theme().textMuted}>{keyListLabel(inputKeys().moveUp)}/{keyListLabel(inputKeys().moveDown)} move · {keyListLabel(inputKeys().normalMode)} normal</text>
585
820
  </box>
586
821
  </Show>
587
822
 
package/tui.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
3
  import { Telescope } from "./telescope.tsx"
4
+ import { loadTelescopeConfig } from "./ui/config.ts"
4
5
 
5
6
  const id = "opencode-telescope"
6
7
 
@@ -13,9 +14,10 @@ const enabled = (options: unknown) => {
13
14
  const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
14
15
  if (!enabled(options)) return
15
16
 
17
+ const config = loadTelescopeConfig()
16
18
  const command = "opencode.telescope.sessions"
17
19
  const open = () => {
18
- api.ui.dialog.replace(() => <Telescope api={api} onClose={() => api.ui.dialog.clear()} />)
20
+ api.ui.dialog.replace(() => <Telescope api={api} config={config} onClose={() => api.ui.dialog.clear()} />)
19
21
  api.ui.dialog.setSize("xlarge")
20
22
  }
21
23
 
@@ -30,7 +32,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
30
32
  run: open,
31
33
  },
32
34
  ],
33
- bindings: [{ key: "<leader>f", desc: "Search conversations", group: "Search", cmd: open }],
35
+ bindings: [{ key: config.openKey, desc: "Search conversations", group: "Search", cmd: open }],
34
36
  })
35
37
 
36
38
  api.lifecycle.onDispose(() => {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { defaultTelescopeConfig, loadTelescopeConfig, parseTelescopeConfig, telescopeConfigPath } from "./config.ts"
6
+
7
+ describe("telescope config", () => {
8
+ test("uses defaults for missing config", () => {
9
+ const dir = mkdtempSync(path.join(tmpdir(), "opencode-telescope-config-"))
10
+ try {
11
+ expect(loadTelescopeConfig(path.join(dir, "missing.json"))).toEqual(defaultTelescopeConfig)
12
+ } finally {
13
+ rmSync(dir, { recursive: true, force: true })
14
+ }
15
+ })
16
+
17
+ test("uses defaults for invalid json", () => {
18
+ const dir = mkdtempSync(path.join(tmpdir(), "opencode-telescope-config-"))
19
+ const file = path.join(dir, "config.json")
20
+ try {
21
+ writeFileSync(file, "{")
22
+ expect(loadTelescopeConfig(file)).toEqual(defaultTelescopeConfig)
23
+ } finally {
24
+ rmSync(dir, { recursive: true, force: true })
25
+ }
26
+ })
27
+
28
+ test("merges valid partial config and ignores invalid fields", () => {
29
+ const config = parseTelescopeConfig({
30
+ openKey: " <leader>s ",
31
+ keys: {
32
+ moveDown: ["n", 1, ""],
33
+ moveUp: [],
34
+ open: "ctrl+o",
35
+ },
36
+ })
37
+
38
+ expect(config.openKey).toBe("<leader>s")
39
+ expect(config.keys.moveDown).toEqual(["n"])
40
+ expect(config.keys.moveUp).toEqual(defaultTelescopeConfig.keys.moveUp)
41
+ expect(config.keys.open).toEqual(["ctrl+o"])
42
+ expect(config.keys.close).toEqual(defaultTelescopeConfig.keys.close)
43
+ })
44
+
45
+ test("resolves config under XDG_CONFIG_HOME", () => {
46
+ expect(telescopeConfigPath({ XDG_CONFIG_HOME: "/tmp/config" })).toBe("/tmp/config/opencode/opencode-telescope/config.json")
47
+ })
48
+ })
package/ui/config.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+
5
+ export type TelescopeKeyAction =
6
+ | "moveDown"
7
+ | "moveUp"
8
+ | "scrollPreviewDown"
9
+ | "scrollPreviewUp"
10
+ | "open"
11
+ | "close"
12
+ | "insertMode"
13
+ | "normalMode"
14
+ | "toggleOwner"
15
+
16
+ export type TelescopeConfig = {
17
+ openKey: string
18
+ keys: Record<TelescopeKeyAction, string[]>
19
+ }
20
+
21
+ export const defaultTelescopeConfig: TelescopeConfig = {
22
+ openKey: "<leader>f",
23
+ keys: {
24
+ moveDown: ["down", "j"],
25
+ moveUp: ["up", "k"],
26
+ scrollPreviewDown: ["d"],
27
+ scrollPreviewUp: ["u"],
28
+ open: ["enter", "return"],
29
+ close: ["q", "escape"],
30
+ insertMode: ["/"],
31
+ normalMode: ["ctrl+q"],
32
+ toggleOwner: ["o"],
33
+ },
34
+ }
35
+
36
+ export function telescopeConfigPath(env: NodeJS.ProcessEnv = process.env) {
37
+ const configHome = env.XDG_CONFIG_HOME || path.join(homedir(), ".config")
38
+ return path.join(configHome, "opencode", "opencode-telescope", "config.json")
39
+ }
40
+
41
+ export function loadTelescopeConfig(configPath = telescopeConfigPath()): TelescopeConfig {
42
+ if (!existsSync(configPath)) return cloneConfig(defaultTelescopeConfig)
43
+
44
+ try {
45
+ return parseTelescopeConfig(JSON.parse(readFileSync(configPath, "utf8")))
46
+ } catch {
47
+ return cloneConfig(defaultTelescopeConfig)
48
+ }
49
+ }
50
+
51
+ export function parseTelescopeConfig(value: unknown): TelescopeConfig {
52
+ const config = cloneConfig(defaultTelescopeConfig)
53
+ if (!isRecord(value)) return config
54
+
55
+ if (typeof value.openKey === "string" && value.openKey.trim()) {
56
+ config.openKey = value.openKey.trim()
57
+ }
58
+
59
+ if (!isRecord(value.keys)) return config
60
+ for (const action of Object.keys(defaultTelescopeConfig.keys) as TelescopeKeyAction[]) {
61
+ const parsed = parseKeyList(value.keys[action])
62
+ if (parsed) config.keys[action] = parsed
63
+ }
64
+
65
+ return config
66
+ }
67
+
68
+ function parseKeyList(value: unknown) {
69
+ if (typeof value === "string") {
70
+ const key = value.trim()
71
+ return key ? [key] : undefined
72
+ }
73
+ if (!Array.isArray(value)) return
74
+
75
+ const keys = value
76
+ .filter((item): item is string => typeof item === "string")
77
+ .map((item) => item.trim())
78
+ .filter(Boolean)
79
+ return keys.length > 0 ? keys : undefined
80
+ }
81
+
82
+ function cloneConfig(config: TelescopeConfig): TelescopeConfig {
83
+ return {
84
+ openKey: config.openKey,
85
+ keys: Object.fromEntries(
86
+ Object.entries(config.keys).map(([action, keys]) => [action, [...keys]]),
87
+ ) as Record<TelescopeKeyAction, string[]>,
88
+ }
89
+ }
90
+
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return Boolean(value && typeof value === "object" && !Array.isArray(value))
93
+ }
@@ -0,0 +1,40 @@
1
+ import type { ParsedKey } from "@opentui/core"
2
+ import { describe, expect, test } from "bun:test"
3
+ import { inputSafeKeys, keyListLabel, matchesKey } from "./keyboard.ts"
4
+
5
+ describe("keyboard helpers", () => {
6
+ test("matches simple key names", () => {
7
+ expect(matchesKey(key("j"), ["j"])).toBe(true)
8
+ expect(matchesKey(key("j"), ["k"])).toBe(false)
9
+ })
10
+
11
+ test("matches ctrl modifier strings", () => {
12
+ expect(matchesKey(key("q", { ctrl: true }), ["ctrl+q"])).toBe(true)
13
+ expect(matchesKey(key("q"), ["ctrl+q"])).toBe(false)
14
+ })
15
+
16
+ test("labels configured key lists", () => {
17
+ expect(keyListLabel(["ctrl+q"])).toBe("^q")
18
+ expect(keyListLabel(["enter", "return"])).toBe("enter/return")
19
+ })
20
+
21
+ test("filters text input unsafe plain character keys", () => {
22
+ expect(inputSafeKeys(["j", "down", "ctrl+j"])).toEqual(["down", "ctrl+j"])
23
+ })
24
+ })
25
+
26
+ function key(name: string, options: Partial<ParsedKey> = {}): ParsedKey {
27
+ return {
28
+ name,
29
+ ctrl: false,
30
+ meta: false,
31
+ shift: false,
32
+ option: false,
33
+ sequence: name,
34
+ number: false,
35
+ raw: name,
36
+ eventType: "press",
37
+ source: "raw",
38
+ ...options,
39
+ }
40
+ }
package/ui/keyboard.ts CHANGED
@@ -4,6 +4,40 @@ export function isKey(evt: ParsedKey, ...names: string[]) {
4
4
  return names.includes(evt.name)
5
5
  }
6
6
 
7
+ export function matchesKey(evt: ParsedKey, keys: string[]) {
8
+ return keys.some((key) => matchesKeyString(evt, key))
9
+ }
10
+
11
+ export function keyListLabel(keys: string[]) {
12
+ return keys.map(keyLabel).join("/")
13
+ }
14
+
15
+ export function inputSafeKeys(keys: string[]) {
16
+ return keys.filter((key) => key.includes("+") || key.length > 1)
17
+ }
18
+
19
+ function matchesKeyString(evt: ParsedKey, key: string) {
20
+ const parts = key.toLowerCase().split("+").map((part) => part.trim()).filter(Boolean)
21
+ const name = parts.at(-1)
22
+ if (!name || evt.name.toLowerCase() !== name) return false
23
+
24
+ const modifiers = new Set(parts.slice(0, -1))
25
+ return Boolean(evt.ctrl) === modifiers.has("ctrl") &&
26
+ Boolean(evt.meta) === modifiers.has("meta") &&
27
+ Boolean(evt.shift) === modifiers.has("shift") &&
28
+ Boolean(evt.option) === (modifiers.has("alt") || modifiers.has("option"))
29
+ }
30
+
31
+ function keyLabel(key: string) {
32
+ return key.split("+")
33
+ .map((part) => {
34
+ const value = part.trim()
35
+ if (value.toLowerCase() === "ctrl") return "^"
36
+ return value
37
+ })
38
+ .join("")
39
+ }
40
+
7
41
  export function prevent(evt: ParsedKey) {
8
42
  const controlled = evt as ParsedKey & {
9
43
  preventDefault?: () => void