Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -124,11 +125,13 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("#")
Expand Down Expand Up @@ -57,6 +58,7 @@ export type AutocompleteOption = {
description?: string
isDirectory?: boolean
onSelect?: () => void
path?: string
}

export function Autocomplete(props: {
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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("/")) {
Expand All @@ -205,6 +222,7 @@ export function Autocomplete(props: {
return {
display: Locale.truncateMiddle(filename, width),
isDirectory: isDir,
path: item,
onSelect: () => {
insertPart(filename, {
type: "file",
Expand Down Expand Up @@ -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)
},
})

Expand Down
89 changes: 89 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx
Original file line number Diff line number Diff line change
@@ -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,
}
},
})