Просмотр исходного кода

feat(cli): frecency file autocomplete (#6603)

galkatz373 1 месяц назад
Родитель
Сommit
d4e7a88bba

+ 8 - 5
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -23,6 +23,7 @@ import { ThemeProvider, useTheme } from "@tui/context/theme"
 import { Home } from "@tui/routes/home"
 import { Home } from "@tui/routes/home"
 import { Session } from "@tui/routes/session"
 import { Session } from "@tui/routes/session"
 import { PromptHistoryProvider } from "./component/prompt/history"
 import { PromptHistoryProvider } from "./component/prompt/history"
+import { FrecencyProvider } from "./component/prompt/frecency"
 import { PromptStashProvider } from "./component/prompt/stash"
 import { PromptStashProvider } from "./component/prompt/stash"
 import { DialogAlert } from "./ui/dialog-alert"
 import { DialogAlert } from "./ui/dialog-alert"
 import { ToastProvider, useToast } from "./ui/toast"
 import { ToastProvider, useToast } from "./ui/toast"
@@ -124,11 +125,13 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
                                 <PromptStashProvider>
                                 <PromptStashProvider>
                                   <DialogProvider>
                                   <DialogProvider>
                                     <CommandProvider>
                                     <CommandProvider>
-                                      <PromptHistoryProvider>
-                                        <PromptRefProvider>
-                                          <App />
-                                        </PromptRefProvider>
-                                      </PromptHistoryProvider>
+                                      <FrecencyProvider>
+                                        <PromptHistoryProvider>
+                                          <PromptRefProvider>
+                                            <App />
+                                          </PromptRefProvider>
+                                        </PromptHistoryProvider>
+                                      </FrecencyProvider>
                                     </CommandProvider>
                                     </CommandProvider>
                                   </DialogProvider>
                                   </DialogProvider>
                                 </PromptStashProvider>
                                 </PromptStashProvider>

+ 23 - 3
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -11,6 +11,7 @@ import { useCommandDialog } from "@tui/component/dialog-command"
 import { useTerminalDimensions } from "@opentui/solid"
 import { useTerminalDimensions } from "@opentui/solid"
 import { Locale } from "@/util/locale"
 import { Locale } from "@/util/locale"
 import type { PromptInfo } from "./history"
 import type { PromptInfo } from "./history"
+import { useFrecency } from "./frecency"
 
 
 function removeLineRange(input: string) {
 function removeLineRange(input: string) {
   const hashIndex = input.lastIndexOf("#")
   const hashIndex = input.lastIndexOf("#")
@@ -57,6 +58,7 @@ export type AutocompleteOption = {
   description?: string
   description?: string
   isDirectory?: boolean
   isDirectory?: boolean
   onSelect?: () => void
   onSelect?: () => void
+  path?: string
 }
 }
 
 
 export function Autocomplete(props: {
 export function Autocomplete(props: {
@@ -76,6 +78,7 @@ export function Autocomplete(props: {
   const command = useCommandDialog()
   const command = useCommandDialog()
   const { theme } = useTheme()
   const { theme } = useTheme()
   const dimensions = useTerminalDimensions()
   const dimensions = useTerminalDimensions()
+  const frecency = useFrecency()
 
 
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     index: 0,
     index: 0,
@@ -168,6 +171,10 @@ export function Autocomplete(props: {
       draft.parts.push(part)
       draft.parts.push(part)
       props.setExtmark(partIndex, extmarkId)
       props.setExtmark(partIndex, extmarkId)
     })
     })
+
+    if (part.type === "file" && part.source && part.source.type === "file") {
+      frecency.updateFrecency(part.source.path)
+    }
   }
   }
 
 
   const [files] = createResource(
   const [files] = createResource(
@@ -186,9 +193,19 @@ export function Autocomplete(props: {
 
 
       // Add file options
       // Add file options
       if (!result.error && result.data) {
       if (!result.error && result.data) {
+        const sortedFiles = result.data.sort((a, b) => {
+          const aScore = frecency.getFrecency(a)
+          const bScore = frecency.getFrecency(b)
+          if (aScore !== bScore) return bScore - aScore
+          const aDepth = a.split("/").length
+          const bDepth = b.split("/").length
+          if (aDepth !== bDepth) return aDepth - bDepth
+          return a.localeCompare(b)
+        })
+
         const width = props.anchor().width - 4
         const width = props.anchor().width - 4
         options.push(
         options.push(
-          ...result.data.map((item): AutocompleteOption => {
+          ...sortedFiles.map((item): AutocompleteOption => {
             let url = `file://${process.cwd()}/${item}`
             let url = `file://${process.cwd()}/${item}`
             let filename = item
             let filename = item
             if (lineRange && !item.endsWith("/")) {
             if (lineRange && !item.endsWith("/")) {
@@ -205,6 +222,7 @@ export function Autocomplete(props: {
             return {
             return {
               display: Locale.truncateMiddle(filename, width),
               display: Locale.truncateMiddle(filename, width),
               isDirectory: isDir,
               isDirectory: isDir,
+              path: item,
               onSelect: () => {
               onSelect: () => {
                 insertPart(filename, {
                 insertPart(filename, {
                   type: "file",
                   type: "file",
@@ -471,10 +489,12 @@ export function Autocomplete(props: {
       limit: 10,
       limit: 10,
       scoreFn: (objResults) => {
       scoreFn: (objResults) => {
         const displayResult = objResults[0]
         const displayResult = objResults[0]
+        let score = objResults.score
         if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
         if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
-          return objResults.score * 2
+          score *= 2
         }
         }
-        return objResults.score
+        const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
+        return score * (1 + frecencyScore)
       },
       },
     })
     })
 
 

+ 89 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx

@@ -0,0 +1,89 @@
+import path from "path"
+import { Global } from "@/global"
+import { onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "../../context/helper"
+import { appendFile } from "fs/promises"
+
+function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
+  if (!entry) return 0
+  const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day
+  const weight = 1 / (1 + daysSince)
+  return entry.frequency * weight
+}
+
+const MAX_FRECENCY_ENTRIES = 1000
+
+export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
+  name: "Frecency",
+  init: () => {
+    const frecencyFile = Bun.file(path.join(Global.Path.state, "frecency.jsonl"))
+    onMount(async () => {
+      const text = await frecencyFile.text().catch(() => "")
+      const lines = text
+        .split("\n")
+        .filter(Boolean)
+        .map((line) => {
+          try {
+            return JSON.parse(line) as { path: string; frequency: number; lastOpen: number }
+          } catch {
+            return null
+          }
+        })
+        .filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null)
+
+      const latest = lines.reduce(
+        (acc, entry) => {
+          acc[entry.path] = entry
+          return acc
+        },
+        {} as Record<string, { path: string; frequency: number; lastOpen: number }>,
+      )
+
+      const sorted = Object.values(latest)
+        .sort((a, b) => b.lastOpen - a.lastOpen)
+        .slice(0, MAX_FRECENCY_ENTRIES)
+
+      setStore(
+        "data",
+        Object.fromEntries(
+          sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
+        ),
+      )
+
+      if (sorted.length > 0) {
+        const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
+        Bun.write(frecencyFile, content).catch(() => {})
+      }
+    })
+
+    const [store, setStore] = createStore({
+      data: {} as Record<string, { frequency: number; lastOpen: number }>,
+    })
+
+    function updateFrecency(filePath: string) {
+      const absolutePath = path.resolve(process.cwd(), filePath)
+      const newEntry = {
+        frequency: (store.data[absolutePath]?.frequency || 0) + 1,
+        lastOpen: Date.now(),
+      }
+      setStore("data", absolutePath, newEntry)
+      appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
+
+      if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
+        const sorted = Object.entries(store.data)
+          .sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
+          .slice(0, MAX_FRECENCY_ENTRIES)
+        setStore("data", Object.fromEntries(sorted))
+        const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
+        Bun.write(frecencyFile, content).catch(() => {})
+      }
+    }
+
+    return {
+      getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]),
+      updateFrecency,
+      data: () => store.data,
+    }
+  },
+})