Beetroot

Beetroot v1.6.0 — Rust Search Engine, No-Focus Window

Beetroot's search engine moved to Rust — accent folding, typo tolerance, prefix matching. Plus a no-focus window that doesn't interrupt your workflow.

The single most annoying thing about Beetroot was that opening it stole focus. You're renaming a file in Explorer, hit F2, start typing — then open Beetroot to grab something from history, and Explorer loses focus. Your rename is gone. Same with IDE refactoring dialogs, search boxes, anything that relies on focus.

v1.6.0 fixes this. And while I was in there, I rewrote the search engine in Rust.

At a glance:

  • No-focus window — Beetroot no longer steals focus from other apps
  • Rust search engine — accent folding, typo tolerance, prefix matching. Fuse.js removed
  • Window position — choose where Beetroot appears: Center, Top Left, Top Right, Bottom Left, Bottom Right
  • 4 new text transforms — Remove spaces, Single line, Sort lines, Remove duplicates
  • Transform menu search — filter through your transforms when the list gets long
  • 10 bug fixes — including Snipping Tool capture, regex crashes, and AI <think> tags

A clipboard manager that doesn't interrupt you

Before v1.6.0, opening Beetroot was like Alt-Tabbing — it took focus from whatever you were doing. F2 rename in Explorer? Gone. IDE refactoring dialog? Lost focus. Any "type here" prompt — interrupted.

This sounds like it should be a one-line fix. It wasn't.

Tauri v2's win.show() calls ShowWindow(SW_SHOW) under the hood, which activates the window. That's fine for a normal app. But Beetroot's frontend runs inside WebView2 (Chromium), and WebView2 has its own ideas about focus. Every standard Win32 approach failed:

ApproachWhy it didn't work
WS_EX_NOACTIVATEWebView2 child process ignores the flag
SW_SHOWNOACTIVATEWebView2 grabs focus anyway
Focus bounce (SetForegroundWindow back)WM_KILLFOCUS already fired — too late
Subclass WM_ACTIVATE on parentWebView2 children bypass parent messages

The fix: LockSetForegroundWindow(LSFW_LOCK) — an OS-level lock that prevents any process (including WebView2's Chromium subprocess) from changing the foreground window. Lock before show, unlock after. A 30-second safety timer auto-unlocks if something goes wrong.

Since Beetroot doesn't have focus, keyboard input doesn't reach it normally. Navigation works through a low-level keyboard hook (WH_KEYBOARD_LL) that intercepts arrow keys, Enter, Escape, Space, and Alt-combinations when the no-focus mode is active. Clicks outside the window hide it — same behavior as Win+V.

Beetroot open over Explorer without stealing focus — the Explorer folder remains selected while Beetroot shows clipboard history

One bonus: Beetroot briefly appears above always-on-top windows like Task Manager when summoned by hotkey. Otherwise you'd never see it when a fullscreen or always-on-top app is active.

Search, rewritten in Rust

The v1.5.1 search was a TypeScript scoring system — 5 phases, Map-based dedup, Fuse.js for fuzzy. It worked well for day-to-day use. But the search ran in the UI thread, and every keystroke pushed all items through IPC into React state. At 500 items that's fine. At 10K it's not.

v1.6.0 moves the entire search to a Rust backend (~700 lines in search.rs). Not Tantivy, not Nucleo — a custom implementation that mirrors the 5-phase architecture from v1.5.1 but runs natively:

PhaseWhat it doesScore
1Contiguous substring in content/note1.0
2Word-start tokens (camelCase, underscore, hyphen)0.75
3Contiguous substring in source app/title0.5
4Word-start tokens in source app/title0.25
5Levenshtein distance ≤ 1 + prefix match ≥ 60%0.05–0.15

Phase 5 replaces Fuse.js entirely. Fuse.js used a modified Bitap algorithm that worked great on short strings but produced garbage on long clipboard content. The new fuzzy phase uses edit distance per word — each word in the query is compared against each word in the content. "timout" matches "timeout" (distance 1). "mecrosoft" doesn't match "Microsoft" (distance 2). Clean, predictable.

Accent folding — "cafe" finds "café", "resume" finds "résumé". The engine uses Unicode NFD decomposition to strip combining marks before matching. Both the query and content are normalized the same way, so diacritics become invisible to search.

The real win isn't speed — it's architecture. The JS search loaded all items into React state on every keystroke via IPC (500 objects per call). The Rust search returns only filtered results + match indices + filter counts in a single IPC call. Less data across the bridge, less React rendering, smoother typing.

ItemsJS (v1.5.1)Rust (v1.6.0)
500~2ms~2ms
10K~7ms
100K~50ms (estimated)

At 500 items the raw search time is the same. The difference is in everything around it — IPC payload, React state updates, render cycles.

Window position

Beetroot used to always appear at center of your current monitor. Now you can pick: Center, Top Left, Top Right, Bottom Left, or Bottom Right.

Beetroot Settings showing Window position options and Remember selected filter toggle

The same settings panel now has Remember selected filter — your last active filter (Starred, Text, Images, etc.) persists between opens instead of resetting to All every time.

If you've added custom AI prompts on top of the built-in transforms, the menu gets long. Now there's a search box at the top — type "upper" to jump to UPPERCASE, "sort" to find Sort lines.

Beetroot transform menu with search bar filtering transforms

4 new text transforms:

TransformWhat it does
Remove spacesStrips all whitespace
Single lineJoins multiline text into one line
Sort linesAlphabetical sort
Remove duplicatesDeduplicates lines

No AI needed — they run instantly, locally.

Other improvements

  • Image source tracking — images now capture which app and window they came from, just like text clips
  • Localized timestamps — "3m ago", "2d ago" labels use the app language instead of always English
  • Unicode Title Case — works correctly with Cyrillic, CJK, Arabic, and other scripts
  • Better AI errors — rate limits and content filter blocks show specific messages instead of generic failures
  • Space toggles preview — press Space to open, Space again to close (previously needed Escape)
  • Pinned mode paste feedback — shows "Copied to clipboard" toast

Bug fixes

Snipping Tool screenshots not captured — two bugs stacked on top of each other. First: Windows' CanIncludeInClipboardHistory format (used by Snipping Tool, Office, Notepad) was being false-positive detected as a password manager signal, so screenshots were dropped. Second: the clipboard plugin checked for file formats before image formats — Snipping Tool sets both, and the file check returned early before reaching the image check.

Regex crashes — patterns like ^, a*, b? that match empty strings caused infinite loops. Now handled gracefully.

AI <think> tags — Anthropic Claude and DeepSeek models sometimes wrap responses in <think>...</think> reasoning tags. These are now stripped from the output.

Starred items sorting — starred clips were incorrectly floating to the top of the All tab. Now strictly chronological. Use the Starred filter to see only starred clips.

Plus: rich text paste duplicates, local AI endpoint blocking the UI for up to 5 seconds, clipboard monitor race on rapid sleep/wake, overlays persisting after hide/show, transform results applying after menu closed, search highlight at wrong position on multiline clips.

How to update

Beetroot will offer to update automatically. Or download v1.6.0 from GitHub.

Discussion

No comment section here — all discussions happen on X.

Max Nardit

Max Nardit

@mnardit

More articles

How I Added 5 AI Providers to a Tauri Desktop App

Building multi-provider AI in Tauri: OpenAI, Gemini, Claude, DeepSeek, and Ollama. Three integration patterns, a Rust proxy for localhost, and why browser security made local models the hardest part.