axellaffite/tilde

A native-feeling file explorer for macOS, written in Rust

Rust

0

47 commits

updated Aug 12, 2026

See the code

README

tilde

A file explorer for macOS, written in Rust.

Status: a usable file manager. Opens where you left off, lists, sorts, filters one folder or searches a whole subtree (⌘F / ⌥⌘F), and live-refreshes its contents with native macOS file icons; navigates with full history, a clickable path bar that can be typed into (⌘L), and the keyboard; multi-selects; renames, creates folders, accepts dropped files, and moves things to the Trash. Previews files in a side panel (⇧⌘P), with the metadata their format carries. No permanent delete exists — removal always goes through the Trash. Typing ai: in the search field turns it into a conversation with a small language model running on your own machine, which can read — and look at — the folder you are in and nothing else. The interface follows a deliberate design language: a calm dark container in which only your files are allowed any colour.

Interaction

GestureResult
Click a column headerSorts by it; clicking the active one reverses
Single-click a rowSelects it alone, and opens the preview panel
⌘-click / ⇧-clickToggles a row / selects the range from the anchor
Double-click a folderDescends into it
Double-click a fileOpens it in the default macOS app, via opener
Right-click a rowMenu: Open, Rename…, New Folder, Move to Trash
The chevron controls, or ⌘[ / ⌘]Back and forward through the navigation history
The up control, or ⌘↑Goes to the enclosing folder; disabled at /
Click a step of the path barGoes to that ancestor
Click the rest of the path bar, or ⌘L / ⇧⌘GTurns it into a field holding the whole path, selected
⏎ in that fieldGoes where it says: a folder is entered, a file is revealed in its folder
Esc in that fieldPuts the path bar back, unchanged
↑ / ↓ (+⇧)Moves the selection (⇧ grows it); scrolls into view
⏎ or ⌘OOpens the selected row
Typing a nameJumps to the first row with that prefix, Finder-style
EscDeselects everything
The folder control, or ⇧⌘NCreates untitled folder, ready to rename
⌘⌫Moves the selection to the Trash
⌘C / ⌘VCopies via the system pasteboard — interoperates with Finder
⌘X then ⌘VCut: the paste moves instead of copying; cut rows dim
⌘⌥VMoves the pasteboard's files here (Finder's "Move Item Here")
⌘DDuplicates the selection under copy names
⌘ASelects every visible row
⌘RRefreshes the listing, keeping the selection
⇧⌘.Toggles hidden files, as in Finder
⇧⌘P or ⌘IToggles the preview panel: contents, details, format metadata
Scrolling sidewaysReveals the columns when the window is too narrow for them
⌘FFocuses the filter: shows only names in this folder containing the text
⌥⌘FFocuses it and switches to searching everything below this folder
The subfolders toggleSwitches the filter between this folder and its whole subtree
Dropping files onto the windowCopies them into the current folder
The eye toggleReveals dot-prefixed entries, without re-reading the disk
The reload controlRe-reads the current directory, keeping the selection
Typing ai: in the search fieldReplaces the listing with a conversation about this folder
⏎ in that fieldAsks the question, and hands the keyboard to the pane's own field
⏎ in the pane's fieldAsks the next question
Esc while an answer is being writtenStops it

The filter is scoped to one location, as Finder's search is: navigating anywhere clears it, and while it is active the pane header counts matches. It comes in two reaches — see Searching. A line beginning ai: is not a filter at all — see The assistant. Dropped folders copy recursively, symlinks are recreated rather than followed, and nothing is ever overwritten — an occupied name is refused.

Between runs the app remembers the window geometry, the directory, the sort, the hidden-file setting, and which model the assistant runs (eframe's persistence feature). A remembered directory that no longer exists falls back to home; corrupt settings degrade to the defaults.

⌘C writes file URLs to the general NSPasteboard — the representation Finder uses — so copying here pastes in Finder and vice versa. Pasting into the folder a file already lives in duplicates it under a copy name, which is also exactly what ⌘D does. ⌘X marks the selection for a move: the next ⌘V relocates instead of copying (renames within a volume, copy-then-delete across volumes), Esc cancels, and copying anything else first — in any app, detected via the pasteboard's change count — turns the cut stale and safely back into a plain paste. The sidebar lists mounted volumes, each with the line icon that stands for it.

The path bar

The first line of the content pane says where you are, and it is a control rather than a caption — the same two states Windows Explorer's address bar has.

Normally it is a trail: a leading glyph saying which root you are under, then one step per level, separated by chevrons. Anything inside the home directory trails from Home; everything else from Computer, and the glyph is what tells the two apart so Home › Desktop cannot be read as a path under /. Clicking an ancestor goes there — one click from six levels down to any level above it, which is what ⌘↑ six times used to be. The last step is where you already are, so it is not a link: it is primary text where its ancestors are dim, which is the whole hierarchy the palette allows.

Clicking anywhere else on the bar — that last step, the empty run after it, or the standing for steps that did not fit — turns it into a field, as ⌘L and Finder's own ⇧⌘G do. ⏎ goes where it says; Esc, or clicking away, puts the trail back untouched.

The field holds the whole path, selected, and never an abbreviation: /Users/you/Pictures, not ~/Pictures, and no Home root either. The two states name a place differently on purpose — the trail is for reading, in the vocabulary of the sidebar beside it, and the field is for typing over, where an abbreviation is only something to undo first. What it accepts is what people type: an absolute path, a ~ one — understanding the habit costs nothing, and what comes back is the full path it means — a name relative to the folder you are in, and .. to walk back up, all resolved lexically because the text is still being typed and may not name anything yet. A folder is entered. A file is not something this window can show, so its folder is entered with the file selected in it. Anything else is a typo, and the field stays open holding it — retyping a long path because of one wrong character is the thing an address bar exists to avoid.

A narrow window cannot show a deep path, so steps are dropped from the front, never the end: which folder you are in is the one part of the trail that must always be readable. What was dropped becomes the leading , and clicking it opens the field, where the path can be read in full.

The preview panel

Clicking a row opens this panel: a click is the gesture that means "this one", and the panel is what answers it. ⇧⌘P closes it again, and it stays closed until the next click — arrowing through the listing does not reopen it, because only a click says "this one".

⇧⌘P (or ⌘I) opens a resizable side panel describing the selected entry — or the folder itself, when nothing is selected: kind, timestamps, full path, and for folders a tree size computed on a background thread. Finder splits these across a preview pane and a Get Info window; here they are one surface, so both shortcuts reach it.

The panel opens with a full-width preview — a hero rather than a row of labels, so it reads as a description of a file and not as a form — chosen in this order:

  1. Its own pixels, for anything macOS can convert — every image format the system knows, HEIC and camera RAW included, plus a PDF's first page.
  2. A text excerpt, for anything that reads as text. Images are tried first because an SVG is valid UTF-8 yet a picture is what is meant.
  3. The file's icon, for everything else.

Only the first 64 KiB of a text file is ever read, so a multi-gigabyte log previews as cheaply as a note, and a NUL byte marks a file as binary rather than text. Files above 64 MiB keep their icon. Exactly one preview is held at a time — only one is ever on screen — keyed by modification time and length, so editing a file re-renders it.

Previews are produced on a worker thread, with the icon standing in for the frame or two a conversion takes, because the conversion is a child process — which is the whole point, and has a story behind it.

Format-specific metadata

Below the hero, the details are collapsible sections of label/value lines. A More Info section appears carrying whatever rows the file's own format has to offer, and is absent entirely where a format has nothing to say — a screenshot has dimensions but no camera, an MP3 a duration but no dimensions:

Kind of fileRows it adds
PicturesDimensions, resolution, colour space, depth
PhotographsCamera, exposure (1/125 s · f/2.8 · ISO 100), focal length
Sound and videoDuration, sample rate, channels, bit rate, codecs
DocumentsPages, title, authors
MusicAlbum, genre, year

Two sources feed it, both out of process or dependency-free, which is the whole point given that asking ImageIO for a file's properties in process is what crashed the app:

  • Spotlight, via mdls. The rich metadata all comes from format-specific importers, and those are the same components that crashed; running them in a child process turns a failure into an empty result instead of a dead app. Spotlight only knows about files it has indexed, so an unindexed volume simply contributes no rows.
  • The file's own header, parsed in a few lines of Rust for PNG, JPEG, GIF and BMP (fs/imagesize.rs). This is what makes dimensions appear even for files Spotlight has never seen, and — unlike Spotlight — it is deterministic enough to test properly. It wins over Spotlight's idea of the size.

Both run on a background thread, because mdls is a process launch and the header read touches the disk; the panel fills in when the answer arrives.

Why previews are decoded in a child process

The obvious way to do this is three lines of NSImage. It killed the app five times: SIGBUS at 0xbad4007 inside IIOReadPlugin::callInitialize — a per-format ImageIO plugin's own lazy initialiser — reached from initWithContentsOfURL: during a frame.

What made it expensive to diagnose is that it is intermittent. The same fifteen screenshots decoded 210 times in a row without complaint in one run, then killed the process on the first preview of the next; a 6016² HEIC decodes fine every time. On the strength of that clean run the in-process path was re-enabled once, and the crash returned within thirty seconds with the identical stack. A clean run proves nothing here — a poisoned dispatch_once sentinel turning up at random reads like a race inside the plugin's own initialisation, and none of it is under this program's control.

Nor can it be contained in process: a bus error is not catchable, and a worker thread does not help, because the signal takes down the process wherever it is raised. So the decoder is put where it can safely die — a child process:

  • sips -Z 512 -s format bmp writes a scaled BMP in about 30 ms (fs/thumbnail.rs). If a plugin faults it costs one child and one missing preview; sips failing for any reason is just None.
  • fs/bmp.rs reads that BMP back — 24- and 32-bit, either row order, self-described channel masks. Writing a small reader beats adding an image crate for one format, the same reasoning that produced fs/imagesize.rs. What sips actually emits is a BITMAPV5HEADER, BI_BITFIELDS, top-down.

The in-process decoder is gone rather than left behind a flag, so no future edit can reintroduce it by flipping a boolean. QLThumbnailGenerator remains the framework-blessed alternative — also out of process — if block-based async FFI is ever worth the trouble.

Verified against the folder that originally crashed: 33 conversions across every image in it, correct aspect ratios, no crash, and no scratch files left in /tmp.

Rows carry the same icons Finder shows, from NSWorkspace by content type — fetched once per file extension, uploaded as a texture, and shared by every row of that kind. Icons are rasterized by drawing the NSImage into an NSBitmapImageRep of a known RGBA format; the shortcut of round-tripping through TIFFRepresentation crashed the app at launch (ImageIO's TIFF writer hits a poisoned dispatch_once, 0xbad4007, inside the did-finish-launching notification). The AppKit bridge is the only unsafe in the crate, confined to ui/icons.rs under a documented lint exception.

The listing also refreshes itself: an FSEvents watch (notify) follows the current directory and re-reads it whenever anything else — a terminal, Finder, a build — changes it. The selection survives a refresh by path, wherever the entries land in the new order. Access events are ignored, so mere reads never trigger a re-list; a directory that cannot be watched degrades to manual reload with a log line.

Editing

  • Rename happens inline in the Name cell, with the stem pre-selected so typing replaces the name but spares the extension. ⏎ or clicking away commits; Esc cancels. The edit is keyed by path, not row index, so a live refresh mid-edit cannot retarget it. Names are validated (no empty names, no /, no shadowing an existing entry — including broken symlinks), and a case-only rename is recognised as such on case-insensitive volumes by comparing inodes.
  • New Folder picks the next free Finder-style name (untitled folder, untitled folder 2, …) with an atomic mkdir probe — two explorers racing on the same directory cannot claim the same name. The new folder is selected and immediately renamable.
  • Move to Trash runs on a background thread, because trashing a large folder takes long enough to freeze a frame. Success shows up through the directory watcher; failure comes back as a status-bar notice.

Sorting

The active column is highlighted and carries a ▲/▼ arrow, so the current order is visible without clicking to find out. Three rules, all in ui/sort.rs:

  • Name and Kind open ascending; Size, Date Modified and Date Created open descending. Clicking "Size" should surface the biggest files, and a date column the most recent, without needing a second click.
  • Folders always lead, whatever the column and direction. Their size is never computed, so letting them scatter through a size sort would spread -- placeholders down the list.
  • Rows with no value sort last in both directions. A -- cell carries no information and should not lead the list just because the arrow flipped, so the direction is applied only between two known values.

Ties break on name ascending, making the order total — it never depends on what read_dir happened to return. Sorting reorders an index list and touches no files; the setting persists across navigation and reload.

An alias to a folder navigates rather than opening, even though the Kind column still calls it Alias — it is something you can descend into, so it also takes the folder icon and sorts with the folders. That distinction is why Entry carries is_dir (resolved through symlinks) separately from kind.

A folder that cannot be read still becomes the current directory, so the error appears in context and Up can get back out. A file that fails to open leaves a message in the status bar rather than failing silently.

What it shows

Columns, in order: Name · Kind · Size · Date Modified · Date Created.

Identity first, then classification, then magnitude, with the timestamps last because they are scanned least often. Details:

  • Name — folder/file icon, dot-prefixed entries dimmed.
  • Kind — Finder-style description (PNG image, Markdown document, Folder, Alias), from a table of common extensions with an XYZ file fallback.
  • Size — decimal units, as macOS uses (1 kB = 1000 B). Folders show --; their size is not computed because that means walking the whole subtree.
  • Date Modified / Date Created — local time, YYYY-MM-DD HH:MM. Creation time comes from the macOS birthtime. Fixed width so the columns align, and ISO order so they sort correctly as strings.

Unreadable metadata renders as -- rather than removing the row: a home directory reliably contains broken symlinks and permission-gated paths, and those must still be visible.

When the window is narrower than the columns need, the table scrolls sideways rather than squeezing them: a timestamp column shaved to sixty points shows nothing anyone can read, and there is no useful answer to "which half of the date would you like". TableBuilder builds its own vertical scroll area and offers no horizontal one, so this is a second scroll area around it, one axis each — which is also what keeps a scroll gesture unambiguous. The width has to be settled before entering that area, because inside it the available width is unbounded and Column::remainder would take an infinite share of it.

Folders sort before files, then case-insensitively by name. Dotfiles are hidden by default; the toolbar's eye toggle reveals them, re-filtering without touching the disk. The reload control re-reads the directory.

Searching

The filter has two reaches, chosen by the toggle beside it:

  • This folder (default) — narrows the rows already read. No disk work at all.
  • Everything below it (⌥⌘F, or the subfolders toggle) — walks the subtree and lists matches from anywhere in it, each row saying which folder it was found in.

The walk is breadth-first, which is what makes the first screenful the useful one: matches near where you are looking arrive before matches buried ten levels down, and it also makes truncation mean something — what got dropped is the deepest part of the tree rather than whichever subtree happened to be walked last. It stops at 2000 matches or 16 levels, and the header says so when it did.

Three things keep it out of the way of typing:

  • It runs on a worker thread, and every directory boundary checks a cancellation flag, so the walk you have moved on from stops within about 40 ms of the next keystroke. Results carry the generation that asked for them, so a late answer to an abandoned search is recognised and dropped rather than flashing onto the screen.
  • file_type before metadata. The overwhelming majority of entries in a tree are walked past, not matched, and on macOS DirEntry::file_type comes back with the directory read itself where metadata is a stat per entry. Asking the cheap question first took a full walk of a real ~/Downloads from 2.5 s to 0.9 s.
  • Two characters minimum. One letter matches most of a home directory, which is neither useful nor cheap; below that the filter just narrows the folder.

Directory symlinks are not descended into — that is how a walk runs forever — but they still match by their own name. Turning the toggle off returns to the folder you started from; navigating away ends the search but keeps the toggle, because the toggle is how you want searching to work rather than a search of its own.

The assistant

Type ai: into the search field and it stops being a filter. The listing is replaced by a conversation about the folder you are in; clear the field and the listing comes straight back, unchanged — nothing about it was thrown away.

ai:which of these did I touch most recently?
ai:what is in the notes file?
ai:is there anything in here about invoices?

Press Return to ask. ai: on its own puts the pane up without asking anything, and puts it up empty — which is also how you start a new conversation.

The pane has a field of its own at the foot of it, and that is where a conversation is actually carried on: asking a follow-up through the search bar would mean reaching back over the answer you are reading to a control at the top of the window. So the search field opens the conversation and asks the first question, and the keyboard is then handed to the pane's field. Both routes end in the same place. Esc stops an answer being written, from either.

Answers are rendered as markdown, because that is what the model writes: asked to list a folder it replies with a bulleted list of names in bold, and as plain text that is a screenful of asterisks. Questions are deliberately not rendered — you typed them, they are not markup, and an underscore in a filename should stay an underscore.

The model runs in this process, on this machine. Nothing is sent anywhere.

What it can do

Four tools, all read-only:

ToolWhat it does
list_filesLists one folder at or below the one on screen
search_filesFinds names containing some text, anywhere below it
read_fileReads the first 32 KB of a text file in it
view_imageLooks at a picture: any image macOS can decode, or a PDF's first page

That is the whole set. It cannot create, rename, move or trash anything — a model that has misread a question should be able to waste your time, not your files. Every path it offers is resolved and then checked to have landed inside the folder you are looking at, after canonicalisation, so a symlink pointing out of the tree is refused rather than followed. Each tool bounds its own output and says so when it truncated: silent truncation is worse than none, because it teaches the model it has seen everything.

Rooting is not a sandbox. The model runs in this process and could in principle be made to do anything this process can; the check is a guard against the ordinary failure, which is a model hallucinating /etc/passwd into an argument.

It can see

ai:what is in this screenshot?
ai:which of these photos has a dog in it?
ai:read the chart in q3.pdf and tell me the trend

Every format macOS can decode — HEIC, camera RAW, PNG, JPEG, and a PDF's first page — because looking at a picture reuses the same out-of-process sips conversion the preview panel does. This crate has learned to decode nothing.

The pixels do not travel in the tool's result. Rig replays every tool result into the prompt on each later turn, so a megabyte of base64 in one would be re-encoded and re-sent for every question that followed. Instead the result carries llama.cpp's own <__media__> marker and the pixels are collected alongside the conversation; mtmd splices the encoded picture in where the marker sits, pairing them by position. That is exactly how llama.cpp's multimodal interface takes them, and it is why the marker constant has a test asserting it still equals mtmd_default_marker() — if it ever drifted, pictures would be silently not looked at.

Two budgets keep an image from eating the conversation: it is scaled to 768 pixels on its longer side before the encoder sees it, and capped at 512 tokens however large it was. One conversation holds four pictures, because each stays in the history for every turn that follows it.

Vision needs a projector — a second GGUF, published beside the weights, that turns pixels into embeddings. A model without one is a working assistant that simply is not offered view_image at all: a tool it could not honour would have it call, fail, and report that something went wrong rather than that it cannot see.

On GLM. GLM's vision models are good and mostly do not fit. GLM-4.1V-9B is the right size but no GGUF repository publishes a projector for it, so llama.cpp cannot see with it. The ones that do ship a projector are GLM-4.5V, a 106B mixture that is ~70 GB at Q4_K_M, and GLM-5.2-Vision, larger still. Both are reachable through the settings below on a machine with the memory for them.

Nothing ships with the app

No weights are bundled, no thread is started, and nothing is downloaded until you ask a first question. A user who never types those three characters pays nothing at all for this existing.

The first question downloads a GGUF from Hugging Face into ~/Library/Caches/tilde/models/ — by default Qwen3 4B at Q4_K_M, about 2.5 GB — and loads it onto the GPU. That takes a few minutes on a domestic connection, and the pane says how far along it is. Every question after that is answered by a model already in memory.

The choice is a saved preference rather than a constant, because the quantisation is a trade you are entitled to make. Two settings: assistant.model in the form owner/name:file.gguf, and assistant.mmproj naming the vision projector beside it in the same repository — empty for a text-only assistant.

assistant.modelassistant.mmproj
Qwen/Qwen3-VL-4B-Instruct-GGUF:Qwen3VL-4B-Instruct-Q4_K_M.ggufmmproj-Qwen3VL-4B-Instruct-F16.ggufthe default, ~3.3 GB
Qwen/Qwen3-VL-8B-Instruct-GGUF:Qwen3VL-8B-Instruct-Q4_K_M.ggufmmproj-Qwen3VL-8B-Instruct-F16.ggufsees better, ~6.2 GB
ggml-org/gemma-3-4b-it-GGUF:gemma-3-4b-it-Q4_K_M.ggufmmproj-model-f16.ggufanother family, ~3.3 GB
Qwen/Qwen3-4B-GGUF:Qwen3-4B-Q4_K_M.gguf(empty)text only, ~2.5 GB
ggml-org/GLM-4.5V-GGUF:GLM-4.5V-Q4_K_M.ggufmmproj-GLM-4.5V-Q8_0.ggufif you have ~71 GB of memory

Anything unparseable falls back to the default rather than stopping the app — the settings file is these preferences' only interface, so they are the ones most likely to be edited into nonsense, and a projector name that could not be a file in the repository is dropped rather than carried into a download that fails every time the assistant starts. The model needs a chat template in its GGUF and should be one trained to call tools; a base model will load and then be useless.

One migration is applied on the way in: a settings file still pinning the text-only Qwen3-4B default from before the assistant could see, with no projector of its own, is upgraded to the current default. That value was written into every settings file whether the user chose it or not, so left alone it would have kept the assistant blind for exactly the people who never touched the setting. Any other remembered model is a decision and is honoured.

How it is put together

rig supplies the agent loop — the tool registry, the JSON-schema definitions, and the multi-turn dispatch that runs a tool, feeds its result back, and asks again. It has no integration for a model on the local disk (every one of its two dozen providers speaks HTTP, and its two local options expect a server you installed separately), so ai/provider.rs implements its CompletionModel trait over llama-cpp-2. That is a couple of hundred lines, against which not having to write the dispatch loop is a good trade.

What a hosted provider does in a JSON field — declaring tools, returning tool calls as structured data — is a convention when the model is a file that takes a string and produces a string. ai/dialect.rs is the one place that knows which convention: the Hermes one that Qwen3 and most small tool-calling models are trained on, with the callable functions inside a <tools> block in the system message and each call a JSON object inside <tool_call> tags. It touches neither llama.cpp nor the network, which is why it is the part with tests — the two hard parts of running a local model are the prompt going in and the parse coming out, and both are pure functions of a string.

The assistant is the one piece of slow work in the app that is not a task::Job. The runtime's workers are deliberately stateless, and a loaded model is two and a half gigabytes of state that has to stay put between questions, so it gets a thread of its own. It owns the weights, the conversation, and the one current-thread tokio runtime that rig's async loop is blocked on; the window sends questions down a channel and drains events back up.

Generation is interruptible between tokens, so an answer you have given up on stops costing you battery at the next token rather than at the end of the paragraph. The answer being written is kept as the raw text the model produced — tags and all — and re-parsed whenever it is drawn, which is what stops the window and the agent loop ever disagreeing about where an answer ends and a tool call begins.

Sampling is seeded, so the same question about the same folder gives the same answer twice. A file explorer that answered differently each time would be unreasonable to trust, and unreasonable to report a bug against.

Design language

The window is a neutral, low-contrast dark container, and the user's files are the only thing in it allowed to be colourful. Every rule below follows from that one idea, and theme.rs states each of them as a test so a future edit has to argue with an assertion rather than with a comment.

Two signals, two colours, never mixed

ColourAnswersDrawn as
#f5a623where is the keyboard?a 2pt bar along the focused region's top edge
#0a84ffwhich item did I pick?a small chip behind that item's own name

Nothing else in the window carries either colour, and the focus colour is named Palette::focus rather than accent precisely so that reaching for it to brighten a button reads as the mistake it is. There are two focusable regions, because there are two things typing can mean here — filtering, and everything else — and focused_region is the same question handle_shortcuts asks before it acts, so the bar cannot claim the keyboard is somewhere it isn't. A test counts the focus-coloured rectangles in a real frame and fails at anything but exactly one.

A selected row and a picked item are deliberately different marks: the row takes a neutral 7%-white wash (something here is selected), and the blue chip sits behind the name (this is the one).

Separation without borders

There is not a single light hairline in the window. Panes are divided by a change in background value plus a one-pixel seam (#232326) that is darker than every surface it separates — the opposite of a border. Rows have no dividers and no zebra striping; whitespace and the hover wash do that work. Separator and table overlines both draw from widgets.noninteractive.bg_stroke, which is set to the seam colour, so even a stray one comes out as a seam.

Hierarchy from weight and colour, never size

Exactly two type sizes exist — 13pt body and 11pt small — and every TextStyle is pinned to one of them, so nothing can quietly introduce a third. A filename and its size are the same size and differ only in colour. The one exception is a previewed text file's own contents, which stay monospace because that is content, not chrome.

Segmented controls, and one baseline

The toolbar's controls are clustered into runs that share a single raised, rounded box: navigation, then the actions, then the search assembly. A run is one unbroken surface — nothing is drawn between its parts, and a part shows itself only when pointed at. Segment is what tells each part which of its corners to round for that moment, so a hover wash cannot spill out of the box's corner or square off an edge that should be curved.

The search assembly is one such run: magnifier, scope toggle, and the field itself, all in one box the same height as the other groups. That is why the field draws no surface of its own — a second surface would break the run into two shapes — and why its hover wash goes into a slot reserved before its text is laid out. The field's height comes from its own margin: TextEdit measures itself as one row of text plus that margin and ignores the height passed to min_size, so without it the bar looks thirty points tall and answers the pointer over fifteen.

All three clusters sit on one baseline, and a test asserts it. This needs saying because egui will not do it for you: in a horizontal layout with Align::Center, each item is centred against however tall the row happened to be when that item was added, so a row whose height grows as it fills puts its clusters on different baselines. The fix is to leave nothing to accumulate — horizontal_top, and a strip of known height for the one cluster that is not naturally group-height.

One kind of icon

Every interface icon is a monochrome 1.5pt stroke drawing, tinted #9a9aa0, defined in glyphs.rs as polylines in a unit box. Drawing them rather than typing them is not a stylistic preference: a glyph resolves against whatever font covers it, so , and 🗀 arrive at three different weights in three different styles — one of them coloured — and any of them can come back as a missing-glyph box on a machine whose font chain differs. Because the geometry is plain data, the tests check it without rendering anything: that each glyph stays inside its box, reaches across it, and that the paired glyphs (back/forward, sort up/down) are exact mirrors.

The exception is imagery that stands for the user's content — a file's type icon, a thumbnail — which keeps its own colours. The sidebar is navigation rather than content, so it uses line glyphs and not the real macOS folder icons.

Where this deviates, and why

  • Dark only. The design is a set of tonal steps inside one narrow dark band. There is no honest way to restate those steps in a light palette without inventing values the design never specified, so theme::apply registers the same visuals for both egui themes and a macOS appearance switch changes nothing.
  • The sidebar's horizontal padding is 8pt, not the 16pt the other panes use. Its selected entry is specified as a full-width pill, and 16 points either side would make it read as an inset block.
  • Failures are stated in plain primary text, not in a warning colour: the palette has one accent and it means keyboard focus. On the raised surface the notice strip uses, primary text is already the most emphatic treatment available.
  • No tag dots. The row spec allows them, but nothing in this app reads Finder tags, and per-row Spotlight lookups would be far too expensive to add for decoration.
  • No grid view, so the tile rules have nothing to apply to.

Stack

ConcernChoiceWhy
GUIeframe / egui 0.35Pure-Rust, no JS toolchain; egui_extras::TableBuilder fits a column-based file list
Markdownegui_commonmarkRenders the assistant's answers; takes its colours from ui.visuals(), so the palette stays in charge
Visionllama-cpp-2's mtmdLoads a projector and turns a picture into embeddings the text model attends to
Directory watchingnotifyWraps macOS FSEvents
DeletingtrashMoves to the macOS Trash rather than unlinking
Opening filesopenerHands a path to the default application, like open(1)
Standard foldersdirsHome, Documents, Downloads, …
File iconsobjc2-app-kitNSWorkspace icons, rasterized via NSBitmapImageRep — deliberately not TIFFRepresentation, whose ImageIO writer can crash at launch
Errorsanyhow + thiserroranyhow at the app boundary, typed errors in filesystem modules
LoggingtracingRUST_LOG=tilde=debug to raise the level
Agent looprig (rig-core + rig-agent)Tool registry, JSON-schema tool definitions, and the multi-turn dispatch loop
Inferencellama-cpp-2GGUF in-process; turns Metal on by itself on Apple silicon
Weightshf-hubFetches the model from Hugging Face on first use, with progress
AsynctokioOne current-thread runtime, purely to block on rig's async loop

Requirements

  • macOS (developed on macOS 26.2)
  • Rust 1.94 or newer (edition 2024)
  • CMake, to build llama.cpp — brew install cmake. It is a build-time requirement only, and only because the assistant links a C++ library.

Using the assistant additionally wants a few gigabytes of disk for the model and the patience to download it once. Apple silicon is strongly preferred: every layer is offloaded to Metal, and the same model left on the CPU is the difference between an answer in seconds and an answer in minutes.

Running

cargo run                                # debug
RUST_LOG=tilde=debug cargo run           # with verbose logging
cargo run --release                      # release

Dependencies are compiled with opt-level = 3 even in debug builds ([profile.dev.package."*"]), so the UI stays at framerate while your own code remains unoptimized and debuggable. The first build is therefore slow; subsequent ones are not.

Checks

cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo test

The assistant's end-to-end tests are ignored by default — they download a model from Hugging Face, which is a precondition no ordinary cargo test should quietly acquire. Everything decidable without weights is covered by the ordinary suite; this is what goes past that point, from the download through to a tool being dispatched and an answer coming back:

cargo test --test assistant_integration -- --ignored --nocapture

It pulls Qwen3 0.6B (~400 MB) rather than the default model, because what is under test is the plumbing rather than the model's judgement. Point it elsewhere with TILDE_TEST_MODEL=owner/name:file.gguf.

Lints are configured in Cargo.toml under [lints]: unsafe_code is denied, and clippy runs with pedantic plus warnings on unwrap/expect/panic — a file explorer meets missing files, permission errors, and broken symlinks constantly, and none of those should abort the process.

Layout

src/
├── main.rs            # binary: logging setup + eframe bootstrap + theme
├── lib.rs             # library root, so tests/ can reach the logic
├── testing.rs         # test-only fixtures (a hand-built BMP)
├── ai/                # the assistant — the only part that is not always there
│   ├── assistant.rs   # its thread: owns the weights, the conversation, the runtime
│   ├── dialect.rs     # tools into the prompt, tool calls out of the answer (pure)
│   ├── llama.rs       # the only module that touches llama.cpp
│   ├── provider.rs    # the local model, presented to rig as a CompletionModel
│   ├── tools.rs       # list / search / read / look, rooted at the folder on screen
│   ├── transcript.rs  # the conversation as the window shows it
│   └── weights.rs     # which GGUF, and fetching it from Hugging Face
├── fs/                # the filesystem — no UI dependency
│   ├── bmp.rs         # reads back what sips writes, so ImageIO stays out
│   ├── entry.rs       # Entry model: name, kind, size, timestamps
│   ├── imagesize.rs   # PNG/JPEG/GIF/BMP dimensions from the header bytes
│   ├── kind.rs        # Folder/File/Symlink + extension -> description
│   ├── listing.rs     # read_dir -> sorted Vec<Entry>, ListError
│   ├── ops.rs         # trash, rename, copy, paste, duplicate, new folder
│   ├── search.rs      # breadth-first name search below a folder, cancellable
│   ├── size.rs        # recursive tree size for the preview panel
│   ├── spotlight.rs   # format metadata via mdls, out of process
│   ├── thumbnail.rs   # image previews via sips, in a child process
│   └── watcher.rs     # FSEvents watch on one directory, via notify
└── ui/
    ├── app.rs         # eframe::App, state, shortcuts, panes, toolbar
    ├── chat.rs        # the conversation, drawn where the file rows usually are
    ├── details.rs     # metadata -> label/value rows, per format
    ├── file_table.rs  # the table, context menu, inline rename field
    ├── format.rs      # bytes and timestamps -> display strings
    ├── glyphs.rs      # every chrome icon, as stroked unit-box polylines
    ├── history.rs     # back/forward navigation trail
    ├── icons.rs       # NSWorkspace icons -> cached egui textures
    ├── inspector.rs   # the preview panel's layout (⇧⌘P, ⌘I)
    ├── listing.rs     # view-model: formatted rows + visibility + order
    ├── pasteboard.rs  # file URLs on the general NSPasteboard
    ├── pathbar.rs     # where you are: clickable steps, or a path to type (⌘L)
    ├── preview.rs     # image / text / icon preview, threaded, one-slot cache
    ├── selection.rs   # multi-select set: click/⌘/⇧ and arrow-key rules
    ├── sidebar.rs     # Finder-style source list: places, volumes, icons
    ├── sort.rs        # sort key, direction, and the row comparator
    └── theme.rs       # the palette, type scale, spacing and system fonts

The fs layer knows nothing about egui, which is what makes it testable without a window. Logic lives in the library; the binary is only a bootstrap. The ai layer knows nothing about egui either, and its two halves are split on the same principle: everything decidable without weights — the prompt, the parse, the tools, the transcript — lives outside llama.rs and is tested.

Two deliberate performance choices

  • Display strings are computed on load, not per frame. egui repaints many times a second; formatting every row each time would be wasted work. Hidden-file filtering likewise resolves to an index list when the toggle changes.
  • The table body is virtualized (TableBuilder::body(…).rows(…)), so only rows scrolled into view are built and a directory of thousands stays cheap.

Eight egui gotchas worth remembering

  • Table cells default to Sense::hover(), which silently swallows clicks. Clickable rows need TableBuilder::sense(egui::Sense::click()), and row.response() only unions cells added before the call.
  • file_table::show returns an Interaction instead of mutating App directly. Navigating replaces the Listing the table is still borrowing, so the action is applied after rendering finishes.
  • Labels are selectable by default, which makes them steal clicks meant for the row underneath: the pointer becomes an I-beam and a drag starts a text selection instead of selecting the file. theme::apply turns interaction.selectable_labels off globally, and the Get Info path opts back in with Label::selectable(true) since nothing there is a click target. Relatedly, visuals.interact_cursor is honoured by Button only — rows, sort headers, sidebar entries and the checkbox each set on_hover_cursor(CursorIcon::PointingHand) themselves.
  • consume_key matches modifiers logically, not exactly: it uses Modifiers::matches_logically, which only requires the modifiers the pattern asks for, ignoring extra ⇧ and ⌥ on the event. So consume_key(NONE, ArrowDown) also swallows ⇧↓, and the more specific shortcut has to be offered the event first — check ⇧↓ before ↓, exactly as egui's own docs advise checking ⇧⌘S before ⌘S. ⌘ and ⌃ are matched strictly, which is why ⌘↑ was never at risk.
  • Panel declaration order decides which pane reaches the window's top edge. The sidebar is declared first, so it runs the window's full height and the toolbar (declared next) is the top strip of everything to its right. That also means only those two panes need the titlebar inset — the preview panel, declared after the toolbar, starts below it and would show 28pt of dead space if given the same margin.
  • egui_extras paints a selected row's text in selection.stroke.color, not in the text colour. The stroke is set to zero width so nothing is outlined, but its colour still has to be the primary text colour, or every selected row quietly takes on a second signal.
  • In a horizontal layout, Align::Center centres each item against the row's height at the time that item was added. A row that grows as it fills — say a toolbar ending in a taller cluster — therefore puts its earlier items on a different baseline from its later ones, and nothing about the code looks wrong. Use horizontal_top and give every cluster a known height instead.
  • Painting behind text you have not laid out yet takes a reserved slot: painter.add(Shape::Noop) before the label, then painter.set(idx, …) once the response reports where the text landed. Painting after the label instead would cover it. That is how the selection chip gets behind a filename whose width is not known in advance. The search assembly's box and the field's hover wash are reserved the same way, because how wide the run ends up depends on how much room the toolbar had left.

Notes on the egui 0.35 API

eframe::App in 0.35 has no update method — the entry point is fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame), and the root Ui carries no margin or background. Wrap content in egui::Frame::central_panel(ui.style()). Older tutorials and LLM-generated snippets targeting update + CentralPanel::show(ctx, …) will not compile.

License

MIT

Contributors

axellaffite

46 commits

XavierClavel

1 commits

axellaffite/tilde

A native-feeling file explorer for macOS, written in Rust

Rust

0

47 commits

updated Aug 12, 2026

See the code

README

tilde

A file explorer for macOS, written in Rust.

Status: a usable file manager. Opens where you left off, lists, sorts, filters one folder or searches a whole subtree (⌘F / ⌥⌘F), and live-refreshes its contents with native macOS file icons; navigates with full history, a clickable path bar that can be typed into (⌘L), and the keyboard; multi-selects; renames, creates folders, accepts dropped files, and moves things to the Trash. Previews files in a side panel (⇧⌘P), with the metadata their format carries. No permanent delete exists — removal always goes through the Trash. Typing ai: in the search field turns it into a conversation with a small language model running on your own machine, which can read — and look at — the folder you are in and nothing else. The interface follows a deliberate design language: a calm dark container in which only your files are allowed any colour.

Interaction

GestureResult
Click a column headerSorts by it; clicking the active one reverses
Single-click a rowSelects it alone, and opens the preview panel
⌘-click / ⇧-clickToggles a row / selects the range from the anchor
Double-click a folderDescends into it
Double-click a fileOpens it in the default macOS app, via opener
Right-click a rowMenu: Open, Rename…, New Folder, Move to Trash
The chevron controls, or ⌘[ / ⌘]Back and forward through the navigation history
The up control, or ⌘↑Goes to the enclosing folder; disabled at /
Click a step of the path barGoes to that ancestor
Click the rest of the path bar, or ⌘L / ⇧⌘GTurns it into a field holding the whole path, selected
⏎ in that fieldGoes where it says: a folder is entered, a file is revealed in its folder
Esc in that fieldPuts the path bar back, unchanged
↑ / ↓ (+⇧)Moves the selection (⇧ grows it); scrolls into view
⏎ or ⌘OOpens the selected row
Typing a nameJumps to the first row with that prefix, Finder-style
EscDeselects everything
The folder control, or ⇧⌘NCreates untitled folder, ready to rename
⌘⌫Moves the selection to the Trash
⌘C / ⌘VCopies via the system pasteboard — interoperates with Finder
⌘X then ⌘VCut: the paste moves instead of copying; cut rows dim
⌘⌥VMoves the pasteboard's files here (Finder's "Move Item Here")
⌘DDuplicates the selection under copy names
⌘ASelects every visible row
⌘RRefreshes the listing, keeping the selection
⇧⌘.Toggles hidden files, as in Finder
⇧⌘P or ⌘IToggles the preview panel: contents, details, format metadata
Scrolling sidewaysReveals the columns when the window is too narrow for them
⌘FFocuses the filter: shows only names in this folder containing the text
⌥⌘FFocuses it and switches to searching everything below this folder
The subfolders toggleSwitches the filter between this folder and its whole subtree
Dropping files onto the windowCopies them into the current folder
The eye toggleReveals dot-prefixed entries, without re-reading the disk
The reload controlRe-reads the current directory, keeping the selection
Typing ai: in the search fieldReplaces the listing with a conversation about this folder
⏎ in that fieldAsks the question, and hands the keyboard to the pane's own field
⏎ in the pane's fieldAsks the next question
Esc while an answer is being writtenStops it

The filter is scoped to one location, as Finder's search is: navigating anywhere clears it, and while it is active the pane header counts matches. It comes in two reaches — see Searching. A line beginning ai: is not a filter at all — see The assistant. Dropped folders copy recursively, symlinks are recreated rather than followed, and nothing is ever overwritten — an occupied name is refused.

Between runs the app remembers the window geometry, the directory, the sort, the hidden-file setting, and which model the assistant runs (eframe's persistence feature). A remembered directory that no longer exists falls back to home; corrupt settings degrade to the defaults.

⌘C writes file URLs to the general NSPasteboard — the representation Finder uses — so copying here pastes in Finder and vice versa. Pasting into the folder a file already lives in duplicates it under a copy name, which is also exactly what ⌘D does. ⌘X marks the selection for a move: the next ⌘V relocates instead of copying (renames within a volume, copy-then-delete across volumes), Esc cancels, and copying anything else first — in any app, detected via the pasteboard's change count — turns the cut stale and safely back into a plain paste. The sidebar lists mounted volumes, each with the line icon that stands for it.

The path bar

The first line of the content pane says where you are, and it is a control rather than a caption — the same two states Windows Explorer's address bar has.

Normally it is a trail: a leading glyph saying which root you are under, then one step per level, separated by chevrons. Anything inside the home directory trails from Home; everything else from Computer, and the glyph is what tells the two apart so Home › Desktop cannot be read as a path under /. Clicking an ancestor goes there — one click from six levels down to any level above it, which is what ⌘↑ six times used to be. The last step is where you already are, so it is not a link: it is primary text where its ancestors are dim, which is the whole hierarchy the palette allows.

Clicking anywhere else on the bar — that last step, the empty run after it, or the standing for steps that did not fit — turns it into a field, as ⌘L and Finder's own ⇧⌘G do. ⏎ goes where it says; Esc, or clicking away, puts the trail back untouched.

The field holds the whole path, selected, and never an abbreviation: /Users/you/Pictures, not ~/Pictures, and no Home root either. The two states name a place differently on purpose — the trail is for reading, in the vocabulary of the sidebar beside it, and the field is for typing over, where an abbreviation is only something to undo first. What it accepts is what people type: an absolute path, a ~ one — understanding the habit costs nothing, and what comes back is the full path it means — a name relative to the folder you are in, and .. to walk back up, all resolved lexically because the text is still being typed and may not name anything yet. A folder is entered. A file is not something this window can show, so its folder is entered with the file selected in it. Anything else is a typo, and the field stays open holding it — retyping a long path because of one wrong character is the thing an address bar exists to avoid.

A narrow window cannot show a deep path, so steps are dropped from the front, never the end: which folder you are in is the one part of the trail that must always be readable. What was dropped becomes the leading , and clicking it opens the field, where the path can be read in full.

The preview panel

Clicking a row opens this panel: a click is the gesture that means "this one", and the panel is what answers it. ⇧⌘P closes it again, and it stays closed until the next click — arrowing through the listing does not reopen it, because only a click says "this one".

⇧⌘P (or ⌘I) opens a resizable side panel describing the selected entry — or the folder itself, when nothing is selected: kind, timestamps, full path, and for folders a tree size computed on a background thread. Finder splits these across a preview pane and a Get Info window; here they are one surface, so both shortcuts reach it.

The panel opens with a full-width preview — a hero rather than a row of labels, so it reads as a description of a file and not as a form — chosen in this order:

  1. Its own pixels, for anything macOS can convert — every image format the system knows, HEIC and camera RAW included, plus a PDF's first page.
  2. A text excerpt, for anything that reads as text. Images are tried first because an SVG is valid UTF-8 yet a picture is what is meant.
  3. The file's icon, for everything else.

Only the first 64 KiB of a text file is ever read, so a multi-gigabyte log previews as cheaply as a note, and a NUL byte marks a file as binary rather than text. Files above 64 MiB keep their icon. Exactly one preview is held at a time — only one is ever on screen — keyed by modification time and length, so editing a file re-renders it.

Previews are produced on a worker thread, with the icon standing in for the frame or two a conversion takes, because the conversion is a child process — which is the whole point, and has a story behind it.

Format-specific metadata

Below the hero, the details are collapsible sections of label/value lines. A More Info section appears carrying whatever rows the file's own format has to offer, and is absent entirely where a format has nothing to say — a screenshot has dimensions but no camera, an MP3 a duration but no dimensions:

Kind of fileRows it adds
PicturesDimensions, resolution, colour space, depth
PhotographsCamera, exposure (1/125 s · f/2.8 · ISO 100), focal length
Sound and videoDuration, sample rate, channels, bit rate, codecs
DocumentsPages, title, authors
MusicAlbum, genre, year

Two sources feed it, both out of process or dependency-free, which is the whole point given that asking ImageIO for a file's properties in process is what crashed the app:

  • Spotlight, via mdls. The rich metadata all comes from format-specific importers, and those are the same components that crashed; running them in a child process turns a failure into an empty result instead of a dead app. Spotlight only knows about files it has indexed, so an unindexed volume simply contributes no rows.
  • The file's own header, parsed in a few lines of Rust for PNG, JPEG, GIF and BMP (fs/imagesize.rs). This is what makes dimensions appear even for files Spotlight has never seen, and — unlike Spotlight — it is deterministic enough to test properly. It wins over Spotlight's idea of the size.

Both run on a background thread, because mdls is a process launch and the header read touches the disk; the panel fills in when the answer arrives.

Why previews are decoded in a child process

The obvious way to do this is three lines of NSImage. It killed the app five times: SIGBUS at 0xbad4007 inside IIOReadPlugin::callInitialize — a per-format ImageIO plugin's own lazy initialiser — reached from initWithContentsOfURL: during a frame.

What made it expensive to diagnose is that it is intermittent. The same fifteen screenshots decoded 210 times in a row without complaint in one run, then killed the process on the first preview of the next; a 6016² HEIC decodes fine every time. On the strength of that clean run the in-process path was re-enabled once, and the crash returned within thirty seconds with the identical stack. A clean run proves nothing here — a poisoned dispatch_once sentinel turning up at random reads like a race inside the plugin's own initialisation, and none of it is under this program's control.

Nor can it be contained in process: a bus error is not catchable, and a worker thread does not help, because the signal takes down the process wherever it is raised. So the decoder is put where it can safely die — a child process:

  • sips -Z 512 -s format bmp writes a scaled BMP in about 30 ms (fs/thumbnail.rs). If a plugin faults it costs one child and one missing preview; sips failing for any reason is just None.
  • fs/bmp.rs reads that BMP back — 24- and 32-bit, either row order, self-described channel masks. Writing a small reader beats adding an image crate for one format, the same reasoning that produced fs/imagesize.rs. What sips actually emits is a BITMAPV5HEADER, BI_BITFIELDS, top-down.

The in-process decoder is gone rather than left behind a flag, so no future edit can reintroduce it by flipping a boolean. QLThumbnailGenerator remains the framework-blessed alternative — also out of process — if block-based async FFI is ever worth the trouble.

Verified against the folder that originally crashed: 33 conversions across every image in it, correct aspect ratios, no crash, and no scratch files left in /tmp.

Rows carry the same icons Finder shows, from NSWorkspace by content type — fetched once per file extension, uploaded as a texture, and shared by every row of that kind. Icons are rasterized by drawing the NSImage into an NSBitmapImageRep of a known RGBA format; the shortcut of round-tripping through TIFFRepresentation crashed the app at launch (ImageIO's TIFF writer hits a poisoned dispatch_once, 0xbad4007, inside the did-finish-launching notification). The AppKit bridge is the only unsafe in the crate, confined to ui/icons.rs under a documented lint exception.

The listing also refreshes itself: an FSEvents watch (notify) follows the current directory and re-reads it whenever anything else — a terminal, Finder, a build — changes it. The selection survives a refresh by path, wherever the entries land in the new order. Access events are ignored, so mere reads never trigger a re-list; a directory that cannot be watched degrades to manual reload with a log line.

Editing

  • Rename happens inline in the Name cell, with the stem pre-selected so typing replaces the name but spares the extension. ⏎ or clicking away commits; Esc cancels. The edit is keyed by path, not row index, so a live refresh mid-edit cannot retarget it. Names are validated (no empty names, no /, no shadowing an existing entry — including broken symlinks), and a case-only rename is recognised as such on case-insensitive volumes by comparing inodes.
  • New Folder picks the next free Finder-style name (untitled folder, untitled folder 2, …) with an atomic mkdir probe — two explorers racing on the same directory cannot claim the same name. The new folder is selected and immediately renamable.
  • Move to Trash runs on a background thread, because trashing a large folder takes long enough to freeze a frame. Success shows up through the directory watcher; failure comes back as a status-bar notice.

Sorting

The active column is highlighted and carries a ▲/▼ arrow, so the current order is visible without clicking to find out. Three rules, all in ui/sort.rs:

  • Name and Kind open ascending; Size, Date Modified and Date Created open descending. Clicking "Size" should surface the biggest files, and a date column the most recent, without needing a second click.
  • Folders always lead, whatever the column and direction. Their size is never computed, so letting them scatter through a size sort would spread -- placeholders down the list.
  • Rows with no value sort last in both directions. A -- cell carries no information and should not lead the list just because the arrow flipped, so the direction is applied only between two known values.

Ties break on name ascending, making the order total — it never depends on what read_dir happened to return. Sorting reorders an index list and touches no files; the setting persists across navigation and reload.

An alias to a folder navigates rather than opening, even though the Kind column still calls it Alias — it is something you can descend into, so it also takes the folder icon and sorts with the folders. That distinction is why Entry carries is_dir (resolved through symlinks) separately from kind.

A folder that cannot be read still becomes the current directory, so the error appears in context and Up can get back out. A file that fails to open leaves a message in the status bar rather than failing silently.

What it shows

Columns, in order: Name · Kind · Size · Date Modified · Date Created.

Identity first, then classification, then magnitude, with the timestamps last because they are scanned least often. Details:

  • Name — folder/file icon, dot-prefixed entries dimmed.
  • Kind — Finder-style description (PNG image, Markdown document, Folder, Alias), from a table of common extensions with an XYZ file fallback.
  • Size — decimal units, as macOS uses (1 kB = 1000 B). Folders show --; their size is not computed because that means walking the whole subtree.
  • Date Modified / Date Created — local time, YYYY-MM-DD HH:MM. Creation time comes from the macOS birthtime. Fixed width so the columns align, and ISO order so they sort correctly as strings.

Unreadable metadata renders as -- rather than removing the row: a home directory reliably contains broken symlinks and permission-gated paths, and those must still be visible.

When the window is narrower than the columns need, the table scrolls sideways rather than squeezing them: a timestamp column shaved to sixty points shows nothing anyone can read, and there is no useful answer to "which half of the date would you like". TableBuilder builds its own vertical scroll area and offers no horizontal one, so this is a second scroll area around it, one axis each — which is also what keeps a scroll gesture unambiguous. The width has to be settled before entering that area, because inside it the available width is unbounded and Column::remainder would take an infinite share of it.

Folders sort before files, then case-insensitively by name. Dotfiles are hidden by default; the toolbar's eye toggle reveals them, re-filtering without touching the disk. The reload control re-reads the directory.

Searching

The filter has two reaches, chosen by the toggle beside it:

  • This folder (default) — narrows the rows already read. No disk work at all.
  • Everything below it (⌥⌘F, or the subfolders toggle) — walks the subtree and lists matches from anywhere in it, each row saying which folder it was found in.

The walk is breadth-first, which is what makes the first screenful the useful one: matches near where you are looking arrive before matches buried ten levels down, and it also makes truncation mean something — what got dropped is the deepest part of the tree rather than whichever subtree happened to be walked last. It stops at 2000 matches or 16 levels, and the header says so when it did.

Three things keep it out of the way of typing:

  • It runs on a worker thread, and every directory boundary checks a cancellation flag, so the walk you have moved on from stops within about 40 ms of the next keystroke. Results carry the generation that asked for them, so a late answer to an abandoned search is recognised and dropped rather than flashing onto the screen.
  • file_type before metadata. The overwhelming majority of entries in a tree are walked past, not matched, and on macOS DirEntry::file_type comes back with the directory read itself where metadata is a stat per entry. Asking the cheap question first took a full walk of a real ~/Downloads from 2.5 s to 0.9 s.
  • Two characters minimum. One letter matches most of a home directory, which is neither useful nor cheap; below that the filter just narrows the folder.

Directory symlinks are not descended into — that is how a walk runs forever — but they still match by their own name. Turning the toggle off returns to the folder you started from; navigating away ends the search but keeps the toggle, because the toggle is how you want searching to work rather than a search of its own.

The assistant

Type ai: into the search field and it stops being a filter. The listing is replaced by a conversation about the folder you are in; clear the field and the listing comes straight back, unchanged — nothing about it was thrown away.

ai:which of these did I touch most recently?
ai:what is in the notes file?
ai:is there anything in here about invoices?

Press Return to ask. ai: on its own puts the pane up without asking anything, and puts it up empty — which is also how you start a new conversation.

The pane has a field of its own at the foot of it, and that is where a conversation is actually carried on: asking a follow-up through the search bar would mean reaching back over the answer you are reading to a control at the top of the window. So the search field opens the conversation and asks the first question, and the keyboard is then handed to the pane's field. Both routes end in the same place. Esc stops an answer being written, from either.

Answers are rendered as markdown, because that is what the model writes: asked to list a folder it replies with a bulleted list of names in bold, and as plain text that is a screenful of asterisks. Questions are deliberately not rendered — you typed them, they are not markup, and an underscore in a filename should stay an underscore.

The model runs in this process, on this machine. Nothing is sent anywhere.

What it can do

Four tools, all read-only:

ToolWhat it does
list_filesLists one folder at or below the one on screen
search_filesFinds names containing some text, anywhere below it
read_fileReads the first 32 KB of a text file in it
view_imageLooks at a picture: any image macOS can decode, or a PDF's first page

That is the whole set. It cannot create, rename, move or trash anything — a model that has misread a question should be able to waste your time, not your files. Every path it offers is resolved and then checked to have landed inside the folder you are looking at, after canonicalisation, so a symlink pointing out of the tree is refused rather than followed. Each tool bounds its own output and says so when it truncated: silent truncation is worse than none, because it teaches the model it has seen everything.

Rooting is not a sandbox. The model runs in this process and could in principle be made to do anything this process can; the check is a guard against the ordinary failure, which is a model hallucinating /etc/passwd into an argument.

It can see

ai:what is in this screenshot?
ai:which of these photos has a dog in it?
ai:read the chart in q3.pdf and tell me the trend

Every format macOS can decode — HEIC, camera RAW, PNG, JPEG, and a PDF's first page — because looking at a picture reuses the same out-of-process sips conversion the preview panel does. This crate has learned to decode nothing.

The pixels do not travel in the tool's result. Rig replays every tool result into the prompt on each later turn, so a megabyte of base64 in one would be re-encoded and re-sent for every question that followed. Instead the result carries llama.cpp's own <__media__> marker and the pixels are collected alongside the conversation; mtmd splices the encoded picture in where the marker sits, pairing them by position. That is exactly how llama.cpp's multimodal interface takes them, and it is why the marker constant has a test asserting it still equals mtmd_default_marker() — if it ever drifted, pictures would be silently not looked at.

Two budgets keep an image from eating the conversation: it is scaled to 768 pixels on its longer side before the encoder sees it, and capped at 512 tokens however large it was. One conversation holds four pictures, because each stays in the history for every turn that follows it.

Vision needs a projector — a second GGUF, published beside the weights, that turns pixels into embeddings. A model without one is a working assistant that simply is not offered view_image at all: a tool it could not honour would have it call, fail, and report that something went wrong rather than that it cannot see.

On GLM. GLM's vision models are good and mostly do not fit. GLM-4.1V-9B is the right size but no GGUF repository publishes a projector for it, so llama.cpp cannot see with it. The ones that do ship a projector are GLM-4.5V, a 106B mixture that is ~70 GB at Q4_K_M, and GLM-5.2-Vision, larger still. Both are reachable through the settings below on a machine with the memory for them.

Nothing ships with the app

No weights are bundled, no thread is started, and nothing is downloaded until you ask a first question. A user who never types those three characters pays nothing at all for this existing.

The first question downloads a GGUF from Hugging Face into ~/Library/Caches/tilde/models/ — by default Qwen3 4B at Q4_K_M, about 2.5 GB — and loads it onto the GPU. That takes a few minutes on a domestic connection, and the pane says how far along it is. Every question after that is answered by a model already in memory.

The choice is a saved preference rather than a constant, because the quantisation is a trade you are entitled to make. Two settings: assistant.model in the form owner/name:file.gguf, and assistant.mmproj naming the vision projector beside it in the same repository — empty for a text-only assistant.

assistant.modelassistant.mmproj
Qwen/Qwen3-VL-4B-Instruct-GGUF:Qwen3VL-4B-Instruct-Q4_K_M.ggufmmproj-Qwen3VL-4B-Instruct-F16.ggufthe default, ~3.3 GB
Qwen/Qwen3-VL-8B-Instruct-GGUF:Qwen3VL-8B-Instruct-Q4_K_M.ggufmmproj-Qwen3VL-8B-Instruct-F16.ggufsees better, ~6.2 GB
ggml-org/gemma-3-4b-it-GGUF:gemma-3-4b-it-Q4_K_M.ggufmmproj-model-f16.ggufanother family, ~3.3 GB
Qwen/Qwen3-4B-GGUF:Qwen3-4B-Q4_K_M.gguf(empty)text only, ~2.5 GB
ggml-org/GLM-4.5V-GGUF:GLM-4.5V-Q4_K_M.ggufmmproj-GLM-4.5V-Q8_0.ggufif you have ~71 GB of memory

Anything unparseable falls back to the default rather than stopping the app — the settings file is these preferences' only interface, so they are the ones most likely to be edited into nonsense, and a projector name that could not be a file in the repository is dropped rather than carried into a download that fails every time the assistant starts. The model needs a chat template in its GGUF and should be one trained to call tools; a base model will load and then be useless.

One migration is applied on the way in: a settings file still pinning the text-only Qwen3-4B default from before the assistant could see, with no projector of its own, is upgraded to the current default. That value was written into every settings file whether the user chose it or not, so left alone it would have kept the assistant blind for exactly the people who never touched the setting. Any other remembered model is a decision and is honoured.

How it is put together

rig supplies the agent loop — the tool registry, the JSON-schema definitions, and the multi-turn dispatch that runs a tool, feeds its result back, and asks again. It has no integration for a model on the local disk (every one of its two dozen providers speaks HTTP, and its two local options expect a server you installed separately), so ai/provider.rs implements its CompletionModel trait over llama-cpp-2. That is a couple of hundred lines, against which not having to write the dispatch loop is a good trade.

What a hosted provider does in a JSON field — declaring tools, returning tool calls as structured data — is a convention when the model is a file that takes a string and produces a string. ai/dialect.rs is the one place that knows which convention: the Hermes one that Qwen3 and most small tool-calling models are trained on, with the callable functions inside a <tools> block in the system message and each call a JSON object inside <tool_call> tags. It touches neither llama.cpp nor the network, which is why it is the part with tests — the two hard parts of running a local model are the prompt going in and the parse coming out, and both are pure functions of a string.

The assistant is the one piece of slow work in the app that is not a task::Job. The runtime's workers are deliberately stateless, and a loaded model is two and a half gigabytes of state that has to stay put between questions, so it gets a thread of its own. It owns the weights, the conversation, and the one current-thread tokio runtime that rig's async loop is blocked on; the window sends questions down a channel and drains events back up.

Generation is interruptible between tokens, so an answer you have given up on stops costing you battery at the next token rather than at the end of the paragraph. The answer being written is kept as the raw text the model produced — tags and all — and re-parsed whenever it is drawn, which is what stops the window and the agent loop ever disagreeing about where an answer ends and a tool call begins.

Sampling is seeded, so the same question about the same folder gives the same answer twice. A file explorer that answered differently each time would be unreasonable to trust, and unreasonable to report a bug against.

Design language

The window is a neutral, low-contrast dark container, and the user's files are the only thing in it allowed to be colourful. Every rule below follows from that one idea, and theme.rs states each of them as a test so a future edit has to argue with an assertion rather than with a comment.

Two signals, two colours, never mixed

ColourAnswersDrawn as
#f5a623where is the keyboard?a 2pt bar along the focused region's top edge
#0a84ffwhich item did I pick?a small chip behind that item's own name

Nothing else in the window carries either colour, and the focus colour is named Palette::focus rather than accent precisely so that reaching for it to brighten a button reads as the mistake it is. There are two focusable regions, because there are two things typing can mean here — filtering, and everything else — and focused_region is the same question handle_shortcuts asks before it acts, so the bar cannot claim the keyboard is somewhere it isn't. A test counts the focus-coloured rectangles in a real frame and fails at anything but exactly one.

A selected row and a picked item are deliberately different marks: the row takes a neutral 7%-white wash (something here is selected), and the blue chip sits behind the name (this is the one).

Separation without borders

There is not a single light hairline in the window. Panes are divided by a change in background value plus a one-pixel seam (#232326) that is darker than every surface it separates — the opposite of a border. Rows have no dividers and no zebra striping; whitespace and the hover wash do that work. Separator and table overlines both draw from widgets.noninteractive.bg_stroke, which is set to the seam colour, so even a stray one comes out as a seam.

Hierarchy from weight and colour, never size

Exactly two type sizes exist — 13pt body and 11pt small — and every TextStyle is pinned to one of them, so nothing can quietly introduce a third. A filename and its size are the same size and differ only in colour. The one exception is a previewed text file's own contents, which stay monospace because that is content, not chrome.

Segmented controls, and one baseline

The toolbar's controls are clustered into runs that share a single raised, rounded box: navigation, then the actions, then the search assembly. A run is one unbroken surface — nothing is drawn between its parts, and a part shows itself only when pointed at. Segment is what tells each part which of its corners to round for that moment, so a hover wash cannot spill out of the box's corner or square off an edge that should be curved.

The search assembly is one such run: magnifier, scope toggle, and the field itself, all in one box the same height as the other groups. That is why the field draws no surface of its own — a second surface would break the run into two shapes — and why its hover wash goes into a slot reserved before its text is laid out. The field's height comes from its own margin: TextEdit measures itself as one row of text plus that margin and ignores the height passed to min_size, so without it the bar looks thirty points tall and answers the pointer over fifteen.

All three clusters sit on one baseline, and a test asserts it. This needs saying because egui will not do it for you: in a horizontal layout with Align::Center, each item is centred against however tall the row happened to be when that item was added, so a row whose height grows as it fills puts its clusters on different baselines. The fix is to leave nothing to accumulate — horizontal_top, and a strip of known height for the one cluster that is not naturally group-height.

One kind of icon

Every interface icon is a monochrome 1.5pt stroke drawing, tinted #9a9aa0, defined in glyphs.rs as polylines in a unit box. Drawing them rather than typing them is not a stylistic preference: a glyph resolves against whatever font covers it, so , and 🗀 arrive at three different weights in three different styles — one of them coloured — and any of them can come back as a missing-glyph box on a machine whose font chain differs. Because the geometry is plain data, the tests check it without rendering anything: that each glyph stays inside its box, reaches across it, and that the paired glyphs (back/forward, sort up/down) are exact mirrors.

The exception is imagery that stands for the user's content — a file's type icon, a thumbnail — which keeps its own colours. The sidebar is navigation rather than content, so it uses line glyphs and not the real macOS folder icons.

Where this deviates, and why

  • Dark only. The design is a set of tonal steps inside one narrow dark band. There is no honest way to restate those steps in a light palette without inventing values the design never specified, so theme::apply registers the same visuals for both egui themes and a macOS appearance switch changes nothing.
  • The sidebar's horizontal padding is 8pt, not the 16pt the other panes use. Its selected entry is specified as a full-width pill, and 16 points either side would make it read as an inset block.
  • Failures are stated in plain primary text, not in a warning colour: the palette has one accent and it means keyboard focus. On the raised surface the notice strip uses, primary text is already the most emphatic treatment available.
  • No tag dots. The row spec allows them, but nothing in this app reads Finder tags, and per-row Spotlight lookups would be far too expensive to add for decoration.
  • No grid view, so the tile rules have nothing to apply to.

Stack

ConcernChoiceWhy
GUIeframe / egui 0.35Pure-Rust, no JS toolchain; egui_extras::TableBuilder fits a column-based file list
Markdownegui_commonmarkRenders the assistant's answers; takes its colours from ui.visuals(), so the palette stays in charge
Visionllama-cpp-2's mtmdLoads a projector and turns a picture into embeddings the text model attends to
Directory watchingnotifyWraps macOS FSEvents
DeletingtrashMoves to the macOS Trash rather than unlinking
Opening filesopenerHands a path to the default application, like open(1)
Standard foldersdirsHome, Documents, Downloads, …
File iconsobjc2-app-kitNSWorkspace icons, rasterized via NSBitmapImageRep — deliberately not TIFFRepresentation, whose ImageIO writer can crash at launch
Errorsanyhow + thiserroranyhow at the app boundary, typed errors in filesystem modules
LoggingtracingRUST_LOG=tilde=debug to raise the level
Agent looprig (rig-core + rig-agent)Tool registry, JSON-schema tool definitions, and the multi-turn dispatch loop
Inferencellama-cpp-2GGUF in-process; turns Metal on by itself on Apple silicon
Weightshf-hubFetches the model from Hugging Face on first use, with progress
AsynctokioOne current-thread runtime, purely to block on rig's async loop

Requirements

  • macOS (developed on macOS 26.2)
  • Rust 1.94 or newer (edition 2024)
  • CMake, to build llama.cpp — brew install cmake. It is a build-time requirement only, and only because the assistant links a C++ library.

Using the assistant additionally wants a few gigabytes of disk for the model and the patience to download it once. Apple silicon is strongly preferred: every layer is offloaded to Metal, and the same model left on the CPU is the difference between an answer in seconds and an answer in minutes.

Running

cargo run                                # debug
RUST_LOG=tilde=debug cargo run           # with verbose logging
cargo run --release                      # release

Dependencies are compiled with opt-level = 3 even in debug builds ([profile.dev.package."*"]), so the UI stays at framerate while your own code remains unoptimized and debuggable. The first build is therefore slow; subsequent ones are not.

Checks

cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo test

The assistant's end-to-end tests are ignored by default — they download a model from Hugging Face, which is a precondition no ordinary cargo test should quietly acquire. Everything decidable without weights is covered by the ordinary suite; this is what goes past that point, from the download through to a tool being dispatched and an answer coming back:

cargo test --test assistant_integration -- --ignored --nocapture

It pulls Qwen3 0.6B (~400 MB) rather than the default model, because what is under test is the plumbing rather than the model's judgement. Point it elsewhere with TILDE_TEST_MODEL=owner/name:file.gguf.

Lints are configured in Cargo.toml under [lints]: unsafe_code is denied, and clippy runs with pedantic plus warnings on unwrap/expect/panic — a file explorer meets missing files, permission errors, and broken symlinks constantly, and none of those should abort the process.

Layout

src/
├── main.rs            # binary: logging setup + eframe bootstrap + theme
├── lib.rs             # library root, so tests/ can reach the logic
├── testing.rs         # test-only fixtures (a hand-built BMP)
├── ai/                # the assistant — the only part that is not always there
│   ├── assistant.rs   # its thread: owns the weights, the conversation, the runtime
│   ├── dialect.rs     # tools into the prompt, tool calls out of the answer (pure)
│   ├── llama.rs       # the only module that touches llama.cpp
│   ├── provider.rs    # the local model, presented to rig as a CompletionModel
│   ├── tools.rs       # list / search / read / look, rooted at the folder on screen
│   ├── transcript.rs  # the conversation as the window shows it
│   └── weights.rs     # which GGUF, and fetching it from Hugging Face
├── fs/                # the filesystem — no UI dependency
│   ├── bmp.rs         # reads back what sips writes, so ImageIO stays out
│   ├── entry.rs       # Entry model: name, kind, size, timestamps
│   ├── imagesize.rs   # PNG/JPEG/GIF/BMP dimensions from the header bytes
│   ├── kind.rs        # Folder/File/Symlink + extension -> description
│   ├── listing.rs     # read_dir -> sorted Vec<Entry>, ListError
│   ├── ops.rs         # trash, rename, copy, paste, duplicate, new folder
│   ├── search.rs      # breadth-first name search below a folder, cancellable
│   ├── size.rs        # recursive tree size for the preview panel
│   ├── spotlight.rs   # format metadata via mdls, out of process
│   ├── thumbnail.rs   # image previews via sips, in a child process
│   └── watcher.rs     # FSEvents watch on one directory, via notify
└── ui/
    ├── app.rs         # eframe::App, state, shortcuts, panes, toolbar
    ├── chat.rs        # the conversation, drawn where the file rows usually are
    ├── details.rs     # metadata -> label/value rows, per format
    ├── file_table.rs  # the table, context menu, inline rename field
    ├── format.rs      # bytes and timestamps -> display strings
    ├── glyphs.rs      # every chrome icon, as stroked unit-box polylines
    ├── history.rs     # back/forward navigation trail
    ├── icons.rs       # NSWorkspace icons -> cached egui textures
    ├── inspector.rs   # the preview panel's layout (⇧⌘P, ⌘I)
    ├── listing.rs     # view-model: formatted rows + visibility + order
    ├── pasteboard.rs  # file URLs on the general NSPasteboard
    ├── pathbar.rs     # where you are: clickable steps, or a path to type (⌘L)
    ├── preview.rs     # image / text / icon preview, threaded, one-slot cache
    ├── selection.rs   # multi-select set: click/⌘/⇧ and arrow-key rules
    ├── sidebar.rs     # Finder-style source list: places, volumes, icons
    ├── sort.rs        # sort key, direction, and the row comparator
    └── theme.rs       # the palette, type scale, spacing and system fonts

The fs layer knows nothing about egui, which is what makes it testable without a window. Logic lives in the library; the binary is only a bootstrap. The ai layer knows nothing about egui either, and its two halves are split on the same principle: everything decidable without weights — the prompt, the parse, the tools, the transcript — lives outside llama.rs and is tested.

Two deliberate performance choices

  • Display strings are computed on load, not per frame. egui repaints many times a second; formatting every row each time would be wasted work. Hidden-file filtering likewise resolves to an index list when the toggle changes.
  • The table body is virtualized (TableBuilder::body(…).rows(…)), so only rows scrolled into view are built and a directory of thousands stays cheap.

Eight egui gotchas worth remembering

  • Table cells default to Sense::hover(), which silently swallows clicks. Clickable rows need TableBuilder::sense(egui::Sense::click()), and row.response() only unions cells added before the call.
  • file_table::show returns an Interaction instead of mutating App directly. Navigating replaces the Listing the table is still borrowing, so the action is applied after rendering finishes.
  • Labels are selectable by default, which makes them steal clicks meant for the row underneath: the pointer becomes an I-beam and a drag starts a text selection instead of selecting the file. theme::apply turns interaction.selectable_labels off globally, and the Get Info path opts back in with Label::selectable(true) since nothing there is a click target. Relatedly, visuals.interact_cursor is honoured by Button only — rows, sort headers, sidebar entries and the checkbox each set on_hover_cursor(CursorIcon::PointingHand) themselves.
  • consume_key matches modifiers logically, not exactly: it uses Modifiers::matches_logically, which only requires the modifiers the pattern asks for, ignoring extra ⇧ and ⌥ on the event. So consume_key(NONE, ArrowDown) also swallows ⇧↓, and the more specific shortcut has to be offered the event first — check ⇧↓ before ↓, exactly as egui's own docs advise checking ⇧⌘S before ⌘S. ⌘ and ⌃ are matched strictly, which is why ⌘↑ was never at risk.
  • Panel declaration order decides which pane reaches the window's top edge. The sidebar is declared first, so it runs the window's full height and the toolbar (declared next) is the top strip of everything to its right. That also means only those two panes need the titlebar inset — the preview panel, declared after the toolbar, starts below it and would show 28pt of dead space if given the same margin.
  • egui_extras paints a selected row's text in selection.stroke.color, not in the text colour. The stroke is set to zero width so nothing is outlined, but its colour still has to be the primary text colour, or every selected row quietly takes on a second signal.
  • In a horizontal layout, Align::Center centres each item against the row's height at the time that item was added. A row that grows as it fills — say a toolbar ending in a taller cluster — therefore puts its earlier items on a different baseline from its later ones, and nothing about the code looks wrong. Use horizontal_top and give every cluster a known height instead.
  • Painting behind text you have not laid out yet takes a reserved slot: painter.add(Shape::Noop) before the label, then painter.set(idx, …) once the response reports where the text landed. Painting after the label instead would cover it. That is how the selection chip gets behind a filename whose width is not known in advance. The search assembly's box and the field's hover wash are reserved the same way, because how wide the run ends up depends on how much room the toolbar had left.

Notes on the egui 0.35 API

eframe::App in 0.35 has no update method — the entry point is fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame), and the root Ui carries no margin or background. Wrap content in egui::Frame::central_panel(ui.style()). Older tutorials and LLM-generated snippets targeting update + CentralPanel::show(ctx, …) will not compile.

License

MIT

Contributors

axellaffite

46 commits

XavierClavel

1 commits

Languages

Rust

100.0%