diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e22423309ae..2af5b21152c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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 { Session } from "@tui/routes/session" import { PromptHistoryProvider } from "./component/prompt/history" +import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { ToastProvider, useToast } from "./ui/toast" @@ -124,11 +125,13 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit - - - - - + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index ae4f18d4cb3..8a6db34242a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/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 { Locale } from "@/util/locale" import type { PromptInfo } from "./history" +import { useFrecency } from "./frecency" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -57,6 +58,7 @@ export type AutocompleteOption = { description?: string isDirectory?: boolean onSelect?: () => void + path?: string } export function Autocomplete(props: { @@ -76,6 +78,7 @@ export function Autocomplete(props: { const command = useCommandDialog() const { theme } = useTheme() const dimensions = useTerminalDimensions() + const frecency = useFrecency() const [store, setStore] = createStore({ index: 0, @@ -168,6 +171,10 @@ export function Autocomplete(props: { draft.parts.push(part) props.setExtmark(partIndex, extmarkId) }) + + if (part.type === "file" && part.source && part.source.type === "file") { + frecency.updateFrecency(part.source.path) + } } const [files] = createResource( @@ -186,9 +193,19 @@ export function Autocomplete(props: { // Add file options 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 options.push( - ...result.data.map((item): AutocompleteOption => { + ...sortedFiles.map((item): AutocompleteOption => { let url = `file://${process.cwd()}/${item}` let filename = item if (lineRange && !item.endsWith("/")) { @@ -205,6 +222,7 @@ export function Autocomplete(props: { return { display: Locale.truncateMiddle(filename, width), isDirectory: isDir, + path: item, onSelect: () => { insertPart(filename, { type: "file", @@ -471,10 +489,12 @@ export function Autocomplete(props: { limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] + let score = objResults.score 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) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx new file mode 100644 index 00000000000..5f8a3920d53 --- /dev/null +++ b/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, + ) + + 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, + }) + + 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, + } + }, +})