Back to blog

Building a Fuzzy Finder in Raw Terminal Mode

why not just use fzf

this is the question everyone asks. fzf exists, it's great, you can pipe stuff into it and get fuzzy matching for free. but Wayu is a TUI app that already manages its own raw terminal mode, its own rendering loop, its own input handling. shelling out to fzf means giving up control of the terminal, waiting for a subprocess, parsing its output, and hoping the user doesn't notice the screen flash when you hand off and take back control.

more importantly I needed custom scoring. Wayu manages shell configurations — PATH entries, aliases, environment variables. when someone types "nod" they probably want NODE_HOME or the node binary path, not some random config that happens to contain those letters scattered across it. fzf's generic scoring doesn't know about my domain. I needed to weight things differently.

so I built the whole thing from scratch. 1,068 lines of Odin. no dependencies beyond the standard library.

the scoring algorithm

the core idea is simple: consecutive character matches are worth more than scattered ones, and matches at word boundaries are worth even more. if you type "fb" and one result has those letters next to each other while another has them separated by twelve characters, the first one should win.

fuzzy_score :: proc(query, target: string) -> int {
    score := 0
    qi := 0
    consecutive := 0
    for ti in 0..<len(target) {
        if qi < len(query) && to_lower(target[ti]) == to_lower(query[qi]) {
            score += 1 + consecutive * 2
            if ti == 0 || target[ti-1] == '/' || target[ti-1] == '_' {
                score += 5  // word boundary bonus
            }
            consecutive += 1
            qi += 1
        } else {
            consecutive = 0
        }
    }
    return qi == len(query) ? score : -1
}

the consecutive * 2 multiplier is doing the heavy lifting. your first matching character is worth 1 point. the second consecutive match is worth 3. the third is worth 5. so a three-character consecutive run scores 9 points while three scattered matches only score 3. that difference is enough to push tight matches to the top.

the word boundary bonus of 5 points handles the "nod" -> NODE_HOME case. matching at the start of a word after _ or / gets a significant boost. this means typing a few characters of a word boundary almost always surfaces the right result even if there are longer targets with scattered matches that technically hit more characters.

returning -1 when qi != len(query) means we didn't match all query characters — the target doesn't match at all.

incremental search

the fuzzy finder runs inside Wayu's existing event loop. every keypress triggers a re-score of the entire candidate list. with a few hundred config entries this takes microseconds — no need for debouncing or background threads.

the flow is: user presses a key, we append it to the query string (or remove the last character for backspace), then we walk every candidate, score it against the new query, filter out the -1s, sort by score descending, and hand the sorted list to the renderer.

I was worried about performance here but Odin makes this kind of tight loop fast. scoring a few hundred strings against a short query is nothing. the bottleneck is always rendering, never scoring.

rendering without flicker

this was the actual hard part. the fuzzy finder shows a text input at the top and a scrollable list of results below it. every keypress changes the results. if you naively clear the screen and redraw everything you get visible flicker, especially over SSH where latency amplifies every frame.

the trick is the same differential rendering I use everywhere else in Wayu's TUI — keep a buffer of what's currently on screen, compute what should be on screen, and only emit escape sequences for the cells that changed. when you type a character and the top three results stay the same but the fourth one changes, you're only redrawing one line instead of the whole list.

cursor positioning matters too. the cursor needs to stay in the input field at the top while the results update below it. I move the cursor to the input position last, after all the list updates, so the terminal never shows it jumping around.

what I learned

building a fuzzy finder from scratch sounds like overkill but it taught me a lot about how tools like fzf actually work under the hood. the scoring algorithm is the easy part — it's maybe 20 lines. the real complexity is in the interaction between input handling, scoring, sorting, and rendering, all happening on every single keypress without the user perceiving any delay.

the tight integration paid off too. the fuzzy finder shares Wayu's rendering pipeline, its color theme, its key bindings. it feels native because it is native. no subprocess handoff, no style mismatch, no flash of a different UI. just 1,068 lines of Odin doing exactly what I need.