Back Original

psychic: a fast file browser with ML-powered ranking

Most days I’m in the terminal cd’ing into directories and opening files in my editor. In any given week it’s mostly the same files and dirs because I’m working on the same projects. I was annoyed I had to remember where files live, and tired of tab-completing the same few paths. My computer should already know which file I want to open!

So I made psychic, a fast terminal-based file browser. It tracks the directories you go to and remembers which files you open. It trains a lightweight ML model on your data and ranks results using that model. It prioritizes interactivity and speed above all else: results update as you type and the UI renders in under 16ms.

Everything about how it works flows from a key design decision: it has to be fast enough to update within one frame after a keypress. I’d rather do 10 fast searches in the space of a couple seconds, than 1 smart search that takes many seconds to complete.

I use psychic every time I’m on my computer. It’s become the main way I move around the filesystem. Some numbers: Since I started using it in October 2025,

(These are from the built-in psychic stats command, which you can run on your own history!)

It’s on homebrew: brew install kshitijl/tap/psychic.

On the left are search results. On the right is a preview of the file or directory currently selected. You type a query in the bottom and the results are filtered and ranked, changing with every letter. If you hit Enter on a file, it opens your terminal editor in the same window and waits in the background. If it’s a directory, it restarts the search from inside there. It remembers every file and dir you open, and a shell integration records every dir you cd into from outside psychic. All of those are included in every search.

It’s like fzf and zoxide had a baby, plus a small ML model that learns your patterns and favorites. It’s doing ad optimization on your own data, to help you navigate your computer faster.

How it works

All your searches and clicks get written to a sqlite db on your computer. psychic trains a LightGBM model on that data. The model learns to predict which files you’ll open, based on your search query. psychic uses the model to rank search results. After every keypress, psychic reruns the model and reranks all results.

For more details, see the README in the repo.

Some key design decisions

The model I use is LightGBM, an implementation of gradient-boosted trees. It’s tiny and fast and trains in seconds. It can learn rules that depend on threshold values. For example, it can learn a rule like “Files above 1 MB are only opened if they’ve been modified in the last 24 hours”, like log files. LightGBM also has the excellent LambdaRank objective, which is specialized for exactly this kind of ranking task.

The latency budget ruled out any model that’s not local: a network call could easily be over 10 ms or worse. It also ruled out LLMs or any kind of transformer model. Linear and logistic regression were too simple and performed much worse than LightGBM.

The model is re-trained from scratch every time you open psychic. This means it always reflects your current usage patterns. Training happens in the background and writes the model to disk when it’s done. We don’t switch to using the new model while you’re looking at results, because it’s jarring when apps do that. Instead, we wait for when you’re not looking at the screen, like when you’re opening a file in an editor or when you click on a directory. Then we switch to the new model. I’m proud of this careful little dance.

When a user has just started using psychic, there aren’t enough clicks to train on. We use a simple model that rewards recent clicks, recent modification time and a fuzzy match between the query and filename. The final score is a weighted sum of the simple model and ML model. With few clicks, the simple model dominates. As more clicks accumulate, the ML model’s score gets higher weight, until it’s basically 1.

How to be psychic

I use a carefully chosen set of features to try to predict which file you’ll open. Some features are simple: how recently you last clicked it within psychic, its modification time, whether the filename starts with the query, a fuzzy text match score between the query and the filename. Here’s a debug pane that shows the features and some performance stats:

debug pane with feature names

This debug pane was very helpful in the beginning for figuring out weird results or slowness. These days psychic is pretty stable but I still like opening it sometimes.

One feature is “number of recent clicks on files that are in the same directory as this one”. This feature helps the model boost files that are in currently active projects.

The feature “clicks for this query” helps the model associate specific queries with specific files, without having to literally remember filenames and query texts. This way, while the model doesn’t directly see filenames, it can still remember associations.

training figures

Every training run writes a pdf with some stats. Here we see a training curve and the gain of each feature.

An episode is defined as everything that happens from opening psychic (or from the last click) to clicking on an item. So a single episode has a bunch of queries, including hitting backspace and trying new queries. The feature “engagements in episode with query” counts the number of times you clicked on a file at any point in an episode that had the current query. So if you always type “es” and end up clicking on “bayesian-estimate.md”, then the model can see that association. Next time you type “es”, the model will put that file at the top of the list.

feature SHAP

Here are SHAP values and a dot chart. These help me understand how much a feature affects the model’s output, and to interpret why a model behaves the way it does. For example, the dot chart shows that the model prefers recently modified files, and dislikes most big files.

Computer go fast

I watch perf closely and spent a lot of effort on a good benchmarking setup, plus perf logging. Every commit has performance benchmarks. If you use psychic and get bad performance, that’s a bug! Let me know and I’ll fix it.

My tech choices were driven by performance. Probably the most important choices were Rust and LightGBM.

Syscalls are expensive but of course this whole program is syscalls. I tried to minimize syscalls. No filesystem syscalls in the main render loop; I carefully made sure that we ask for file metadata at the time we walk the filesystem and then carry that around everywhere else.