From 7c1c1d3c0effccd07b5801fa2bbaa490e3077c15 Mon Sep 17 00:00:00 2001 From: Gal Katz Date: Fri, 2 Jan 2026 00:07:15 +0200 Subject: [PATCH 1/4] feat(cli): frecency file autocomplete --- packages/opencode/src/cli/cmd/tui/app.tsx | 13 ++-- .../cmd/tui/component/prompt/autocomplete.tsx | 26 ++++++- .../cli/cmd/tui/component/prompt/frecency.tsx | 76 +++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx 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..6094a2acfb2 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?.path) { + 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..0e09f4ab33e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -0,0 +1,76 @@ +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, writeFile } 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 +} + +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, + ) + + setStore( + "data", + Object.fromEntries( + Object.entries(latest).map(([k, v]) => [k, { frequency: v.frequency, lastOpen: v.lastOpen }]), + ), + ) + + if (Object.keys(latest).length > 0) { + const content = + Object.values(latest) + .map((entry) => JSON.stringify(entry)) + .join("\n") + "\n" + writeFile(frecencyFile.name!, content).catch(() => {}) + } + }) + + const [store, setStore] = createStore({ + data: {} as Record, + }) + + function updateFrecency(filePath: string) { + const newEntry = { + frequency: (store.data[filePath]?.frequency || 0) + 1, + lastOpen: Date.now(), + } + setStore("data", filePath, newEntry) + appendFile(frecencyFile.name!, JSON.stringify({ path: filePath, ...newEntry }) + "\n").catch(() => {}) + } + + return { + getFrecency: (filePath: string) => calculateFrecency(store.data[filePath]), + updateFrecency, + data: () => store.data, + } + }, +}) From dfcb4de192b555dd4946d655d732830e6af2ca2d Mon Sep 17 00:00:00 2001 From: Gal Katz Date: Fri, 2 Jan 2026 10:21:00 +0200 Subject: [PATCH 2/4] feat(cli): frecency file autocomplete --- .../src/cli/cmd/tui/component/prompt/frecency.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx index 0e09f4ab33e..9b1b87b8e29 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -3,7 +3,7 @@ import { Global } from "@/global" import { onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../../context/helper" -import { appendFile, writeFile } from "fs/promises" +import { appendFile } from "fs/promises" function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number { if (!entry) return 0 @@ -50,7 +50,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont Object.values(latest) .map((entry) => JSON.stringify(entry)) .join("\n") + "\n" - writeFile(frecencyFile.name!, content).catch(() => {}) + Bun.write(frecencyFile, content).catch(() => {}) } }) @@ -59,16 +59,17 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont }) function updateFrecency(filePath: string) { + const absolutePath = path.resolve(process.cwd(), filePath) const newEntry = { - frequency: (store.data[filePath]?.frequency || 0) + 1, + frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now(), } - setStore("data", filePath, newEntry) - appendFile(frecencyFile.name!, JSON.stringify({ path: filePath, ...newEntry }) + "\n").catch(() => {}) + setStore("data", absolutePath, newEntry) + appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) } return { - getFrecency: (filePath: string) => calculateFrecency(store.data[filePath]), + getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]), updateFrecency, data: () => store.data, } From c9cef32680276abeb7a16a0b5e7bf610dba8b300 Mon Sep 17 00:00:00 2001 From: Gal Katz Date: Fri, 2 Jan 2026 10:39:18 +0200 Subject: [PATCH 3/4] feat(cli): frecency file autocomplete --- .../cli/cmd/tui/component/prompt/frecency.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx index 9b1b87b8e29..5f8a3920d53 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -12,6 +12,8 @@ function calculateFrecency(entry?: { frequency: number; lastOpen: number }): num return entry.frequency * weight } +const MAX_FRECENCY_ENTRIES = 1000 + export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({ name: "Frecency", init: () => { @@ -38,18 +40,19 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont {} as Record, ) + const sorted = Object.values(latest) + .sort((a, b) => b.lastOpen - a.lastOpen) + .slice(0, MAX_FRECENCY_ENTRIES) + setStore( "data", Object.fromEntries( - Object.entries(latest).map(([k, v]) => [k, { frequency: v.frequency, lastOpen: v.lastOpen }]), + sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]), ), ) - if (Object.keys(latest).length > 0) { - const content = - Object.values(latest) - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n" + if (sorted.length > 0) { + const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n" Bun.write(frecencyFile, content).catch(() => {}) } }) @@ -66,6 +69,15 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont } 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 { From ab7bfab48fd3221dd0d0ddac61600ab6054d5b68 Mon Sep 17 00:00:00 2001 From: Gal Katz Date: Sun, 4 Jan 2026 22:02:32 +0200 Subject: [PATCH 4/4] feat(cli): frecency file autocomplete --- .../opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6094a2acfb2..8a6db34242a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -172,7 +172,7 @@ export function Autocomplete(props: { props.setExtmark(partIndex, extmarkId) }) - if (part.type === "file" && part.source?.path) { + if (part.type === "file" && part.source && part.source.type === "file") { frecency.updateFrecency(part.source.path) } }