@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 +39 -1
- package/components/result-list.tsx +2 -2
- package/package.json +1 -1
- package/search.ts +19 -11
- package/telescope.tsx +350 -115
- package/tui.tsx +4 -2
- package/ui/config.test.ts +48 -0
- package/ui/config.ts +93 -0
- package/ui/keyboard.test.ts +40 -0
- package/ui/keyboard.ts +34 -0
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 `
|
|
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() ?
|
|
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.
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
|
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() *
|
|
72
|
-
const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() *
|
|
73
|
-
const resultPrefetchThreshold = () =>
|
|
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
|
|
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
|
|
80
|
-
const
|
|
81
|
-
return { start, end, items:
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
175
|
-
: recentSessionMessages({ limit, offset
|
|
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 =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 =
|
|
212
|
-
else if (next
|
|
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 =
|
|
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
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
373
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
651
|
+
matchesKey(evt, props.config.keys.moveDown) ? move(1) : move(-1)
|
|
418
652
|
return
|
|
419
653
|
}
|
|
420
654
|
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 (
|
|
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
|
-
|
|
725
|
+
open()
|
|
489
726
|
return
|
|
490
727
|
}
|
|
491
|
-
if (
|
|
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() ?
|
|
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:
|
|
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
|
-
<
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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}
|
|
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:
|
|
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
|