Skip to content

Commit d4e7a88

Browse files
authored
feat(cli): frecency file autocomplete (#6603)
1 parent 630476a commit d4e7a88

File tree

3 files changed

+120
-8
lines changed

3 files changed

+120
-8
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ThemeProvider, useTheme } from "@tui/context/theme"
2323
import { Home } from "@tui/routes/home"
2424
import { Session } from "@tui/routes/session"
2525
import { PromptHistoryProvider } from "./component/prompt/history"
26+
import { FrecencyProvider } from "./component/prompt/frecency"
2627
import { PromptStashProvider } from "./component/prompt/stash"
2728
import { DialogAlert } from "./ui/dialog-alert"
2829
import { ToastProvider, useToast } from "./ui/toast"
@@ -124,11 +125,13 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
124125
<PromptStashProvider>
125126
<DialogProvider>
126127
<CommandProvider>
127-
<PromptHistoryProvider>
128-
<PromptRefProvider>
129-
<App />
130-
</PromptRefProvider>
131-
</PromptHistoryProvider>
128+
<FrecencyProvider>
129+
<PromptHistoryProvider>
130+
<PromptRefProvider>
131+
<App />
132+
</PromptRefProvider>
133+
</PromptHistoryProvider>
134+
</FrecencyProvider>
132135
</CommandProvider>
133136
</DialogProvider>
134137
</PromptStashProvider>

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useCommandDialog } from "@tui/component/dialog-command"
1111
import { useTerminalDimensions } from "@opentui/solid"
1212
import { Locale } from "@/util/locale"
1313
import type { PromptInfo } from "./history"
14+
import { useFrecency } from "./frecency"
1415

1516
function removeLineRange(input: string) {
1617
const hashIndex = input.lastIndexOf("#")
@@ -57,6 +58,7 @@ export type AutocompleteOption = {
5758
description?: string
5859
isDirectory?: boolean
5960
onSelect?: () => void
61+
path?: string
6062
}
6163

6264
export function Autocomplete(props: {
@@ -76,6 +78,7 @@ export function Autocomplete(props: {
7678
const command = useCommandDialog()
7779
const { theme } = useTheme()
7880
const dimensions = useTerminalDimensions()
81+
const frecency = useFrecency()
7982

8083
const [store, setStore] = createStore({
8184
index: 0,
@@ -168,6 +171,10 @@ export function Autocomplete(props: {
168171
draft.parts.push(part)
169172
props.setExtmark(partIndex, extmarkId)
170173
})
174+
175+
if (part.type === "file" && part.source && part.source.type === "file") {
176+
frecency.updateFrecency(part.source.path)
177+
}
171178
}
172179

173180
const [files] = createResource(
@@ -186,9 +193,19 @@ export function Autocomplete(props: {
186193

187194
// Add file options
188195
if (!result.error && result.data) {
196+
const sortedFiles = result.data.sort((a, b) => {
197+
const aScore = frecency.getFrecency(a)
198+
const bScore = frecency.getFrecency(b)
199+
if (aScore !== bScore) return bScore - aScore
200+
const aDepth = a.split("/").length
201+
const bDepth = b.split("/").length
202+
if (aDepth !== bDepth) return aDepth - bDepth
203+
return a.localeCompare(b)
204+
})
205+
189206
const width = props.anchor().width - 4
190207
options.push(
191-
...result.data.map((item): AutocompleteOption => {
208+
...sortedFiles.map((item): AutocompleteOption => {
192209
let url = `file://${process.cwd()}/${item}`
193210
let filename = item
194211
if (lineRange && !item.endsWith("/")) {
@@ -205,6 +222,7 @@ export function Autocomplete(props: {
205222
return {
206223
display: Locale.truncateMiddle(filename, width),
207224
isDirectory: isDir,
225+
path: item,
208226
onSelect: () => {
209227
insertPart(filename, {
210228
type: "file",
@@ -471,10 +489,12 @@ export function Autocomplete(props: {
471489
limit: 10,
472490
scoreFn: (objResults) => {
473491
const displayResult = objResults[0]
492+
let score = objResults.score
474493
if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
475-
return objResults.score * 2
494+
score *= 2
476495
}
477-
return objResults.score
496+
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
497+
return score * (1 + frecencyScore)
478498
},
479499
})
480500

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import path from "path"
2+
import { Global } from "@/global"
3+
import { onMount } from "solid-js"
4+
import { createStore } from "solid-js/store"
5+
import { createSimpleContext } from "../../context/helper"
6+
import { appendFile } from "fs/promises"
7+
8+
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
9+
if (!entry) return 0
10+
const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day
11+
const weight = 1 / (1 + daysSince)
12+
return entry.frequency * weight
13+
}
14+
15+
const MAX_FRECENCY_ENTRIES = 1000
16+
17+
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
18+
name: "Frecency",
19+
init: () => {
20+
const frecencyFile = Bun.file(path.join(Global.Path.state, "frecency.jsonl"))
21+
onMount(async () => {
22+
const text = await frecencyFile.text().catch(() => "")
23+
const lines = text
24+
.split("\n")
25+
.filter(Boolean)
26+
.map((line) => {
27+
try {
28+
return JSON.parse(line) as { path: string; frequency: number; lastOpen: number }
29+
} catch {
30+
return null
31+
}
32+
})
33+
.filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null)
34+
35+
const latest = lines.reduce(
36+
(acc, entry) => {
37+
acc[entry.path] = entry
38+
return acc
39+
},
40+
{} as Record<string, { path: string; frequency: number; lastOpen: number }>,
41+
)
42+
43+
const sorted = Object.values(latest)
44+
.sort((a, b) => b.lastOpen - a.lastOpen)
45+
.slice(0, MAX_FRECENCY_ENTRIES)
46+
47+
setStore(
48+
"data",
49+
Object.fromEntries(
50+
sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
51+
),
52+
)
53+
54+
if (sorted.length > 0) {
55+
const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
56+
Bun.write(frecencyFile, content).catch(() => {})
57+
}
58+
})
59+
60+
const [store, setStore] = createStore({
61+
data: {} as Record<string, { frequency: number; lastOpen: number }>,
62+
})
63+
64+
function updateFrecency(filePath: string) {
65+
const absolutePath = path.resolve(process.cwd(), filePath)
66+
const newEntry = {
67+
frequency: (store.data[absolutePath]?.frequency || 0) + 1,
68+
lastOpen: Date.now(),
69+
}
70+
setStore("data", absolutePath, newEntry)
71+
appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
72+
73+
if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
74+
const sorted = Object.entries(store.data)
75+
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
76+
.slice(0, MAX_FRECENCY_ENTRIES)
77+
setStore("data", Object.fromEntries(sorted))
78+
const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
79+
Bun.write(frecencyFile, content).catch(() => {})
80+
}
81+
}
82+
83+
return {
84+
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]),
85+
updateFrecency,
86+
data: () => store.data,
87+
}
88+
},
89+
})

0 commit comments

Comments
 (0)