A native-feeling file explorer for macOS, written in Rust
Rust
0
47 commits
updated Aug 12, 2026
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.
| Gesture | Result |
|---|---|
| Click a column header | Sorts by it; clicking the active one reverses |
| Single-click a row | Selects it alone, and opens the preview panel |
| ⌘-click / ⇧-click | Toggles a row / selects the range from the anchor |
| Double-click a folder | Descends into it |
| Double-click a file | Opens it in the default macOS app, via opener |
| Right-click a row | Menu: 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 bar | Goes to that ancestor |
| Click the rest of the path bar, or ⌘L / ⇧⌘G | Turns it into a field holding the whole path, selected |
| ⏎ in that field | Goes where it says: a folder is entered, a file is revealed in its folder |
| Esc in that field | Puts the path bar back, unchanged |
| ↑ / ↓ (+⇧) | Moves the selection (⇧ grows it); scrolls into view |
| ⏎ or ⌘O | Opens the selected row |
| Typing a name | Jumps to the first row with that prefix, Finder-style |
| Esc | Deselects everything |
| The folder control, or ⇧⌘N | Creates untitled folder, ready to rename |
| ⌘⌫ | Moves the selection to the Trash |
| ⌘C / ⌘V | Copies via the system pasteboard — interoperates with Finder |
| ⌘X then ⌘V | Cut: the paste moves instead of copying; cut rows dim |
| ⌘⌥V | Moves the pasteboard's files here (Finder's "Move Item Here") |
| ⌘D | Duplicates the selection under copy names |
| ⌘A | Selects every visible row |
| ⌘R | Refreshes the listing, keeping the selection |
| ⇧⌘. | Toggles hidden files, as in Finder |
| ⇧⌘P or ⌘I | Toggles the preview panel: contents, details, format metadata |
| Scrolling sideways | Reveals the columns when the window is too narrow for them |
| ⌘F | Focuses the filter: shows only names in this folder containing the text |
| ⌥⌘F | Focuses it and switches to searching everything below this folder |
| The subfolders toggle | Switches the filter between this folder and its whole subtree |
| Dropping files onto the window | Copies them into the current folder |
| The eye toggle | Reveals dot-prefixed entries, without re-reading the disk |
| The reload control | Re-reads the current directory, keeping the selection |
Typing ai: in the search field | Replaces the listing with a conversation about this folder |
| ⏎ in that field | Asks the question, and hands the keyboard to the pane's own field |
| ⏎ in the pane's field | Asks the next question |
| Esc while an answer is being written | Stops 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 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.
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:
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.
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 file | Rows it adds |
|---|---|
| Pictures | Dimensions, resolution, colour space, depth |
| Photographs | Camera, exposure (1/125 s · f/2.8 · ISO 100), focal length |
| Sound and video | Duration, sample rate, channels, bit rate, codecs |
| Documents | Pages, title, authors |
| Music | Album, 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:
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.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.
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.
/, no shadowing an existing entry — including broken symlinks), and a
case-only rename is recognised as such on case-insensitive volumes by
comparing inodes.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.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:
--
placeholders down the list.-- 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.
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:
PNG image, Markdown document,
Folder, Alias), from a table of common extensions with an
XYZ file fallback.--;
their size is not computed because that means walking the whole subtree.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.
The filter has two reaches, chosen by the toggle beside it:
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:
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.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.
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.
Four tools, all read-only:
| Tool | What it does |
|---|---|
list_files | Lists one folder at or below the one on screen |
search_files | Finds names containing some text, anywhere below it |
read_file | Reads the first 32 KB of a text file in it |
view_image | Looks 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.
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.
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.model | assistant.mmproj | |
|---|---|---|
Qwen/Qwen3-VL-4B-Instruct-GGUF:Qwen3VL-4B-Instruct-Q4_K_M.gguf | mmproj-Qwen3VL-4B-Instruct-F16.gguf | the default, ~3.3 GB |
Qwen/Qwen3-VL-8B-Instruct-GGUF:Qwen3VL-8B-Instruct-Q4_K_M.gguf | mmproj-Qwen3VL-8B-Instruct-F16.gguf | sees better, ~6.2 GB |
ggml-org/gemma-3-4b-it-GGUF:gemma-3-4b-it-Q4_K_M.gguf | mmproj-model-f16.gguf | another 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.gguf | mmproj-GLM-4.5V-Q8_0.gguf | if 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.
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.
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.
| Colour | Answers | Drawn as |
|---|---|---|
#f5a623 | where is the keyboard? | a 2pt bar along the focused region's top edge |
#0a84ff | which 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).
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.
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.
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.
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.
theme::apply registers the
same visuals for both egui themes and a macOS appearance switch changes
nothing.| Concern | Choice | Why |
|---|---|---|
| GUI | eframe / egui 0.35 | Pure-Rust, no JS toolchain; egui_extras::TableBuilder fits a column-based file list |
| Markdown | egui_commonmark | Renders the assistant's answers; takes its colours from ui.visuals(), so the palette stays in charge |
| Vision | llama-cpp-2's mtmd | Loads a projector and turns a picture into embeddings the text model attends to |
| Directory watching | notify | Wraps macOS FSEvents |
| Deleting | trash | Moves to the macOS Trash rather than unlinking |
| Opening files | opener | Hands a path to the default application, like open(1) |
| Standard folders | dirs | Home, Documents, Downloads, … |
| File icons | objc2-app-kit | NSWorkspace icons, rasterized via NSBitmapImageRep — deliberately not TIFFRepresentation, whose ImageIO writer can crash at launch |
| Errors | anyhow + thiserror | anyhow at the app boundary, typed errors in filesystem modules |
| Logging | tracing | RUST_LOG=tilde=debug to raise the level |
| Agent loop | rig (rig-core + rig-agent) | Tool registry, JSON-schema tool definitions, and the multi-turn dispatch loop |
| Inference | llama-cpp-2 | GGUF in-process; turns Metal on by itself on Apple silicon |
| Weights | hf-hub | Fetches the model from Hugging Face on first use, with progress |
| Async | tokio | One current-thread runtime, purely to block on rig's async loop |
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.
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.
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.
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.
TableBuilder::body(…).rows(…)), so only
rows scrolled into view are built and a directory of thousands stays cheap.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.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.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.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.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.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.
MIT
46 commits
1 commits
Rust
100.0%
A native-feeling file explorer for macOS, written in Rust
Rust
0
47 commits
updated Aug 12, 2026
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.
| Gesture | Result |
|---|---|
| Click a column header | Sorts by it; clicking the active one reverses |
| Single-click a row | Selects it alone, and opens the preview panel |
| ⌘-click / ⇧-click | Toggles a row / selects the range from the anchor |
| Double-click a folder | Descends into it |
| Double-click a file | Opens it in the default macOS app, via opener |
| Right-click a row | Menu: 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 bar | Goes to that ancestor |
| Click the rest of the path bar, or ⌘L / ⇧⌘G | Turns it into a field holding the whole path, selected |
| ⏎ in that field | Goes where it says: a folder is entered, a file is revealed in its folder |
| Esc in that field | Puts the path bar back, unchanged |
| ↑ / ↓ (+⇧) | Moves the selection (⇧ grows it); scrolls into view |
| ⏎ or ⌘O | Opens the selected row |
| Typing a name | Jumps to the first row with that prefix, Finder-style |
| Esc | Deselects everything |
| The folder control, or ⇧⌘N | Creates untitled folder, ready to rename |
| ⌘⌫ | Moves the selection to the Trash |
| ⌘C / ⌘V | Copies via the system pasteboard — interoperates with Finder |
| ⌘X then ⌘V | Cut: the paste moves instead of copying; cut rows dim |
| ⌘⌥V | Moves the pasteboard's files here (Finder's "Move Item Here") |
| ⌘D | Duplicates the selection under copy names |
| ⌘A | Selects every visible row |
| ⌘R | Refreshes the listing, keeping the selection |
| ⇧⌘. | Toggles hidden files, as in Finder |
| ⇧⌘P or ⌘I | Toggles the preview panel: contents, details, format metadata |
| Scrolling sideways | Reveals the columns when the window is too narrow for them |
| ⌘F | Focuses the filter: shows only names in this folder containing the text |
| ⌥⌘F | Focuses it and switches to searching everything below this folder |
| The subfolders toggle | Switches the filter between this folder and its whole subtree |
| Dropping files onto the window | Copies them into the current folder |
| The eye toggle | Reveals dot-prefixed entries, without re-reading the disk |
| The reload control | Re-reads the current directory, keeping the selection |
Typing ai: in the search field | Replaces the listing with a conversation about this folder |
| ⏎ in that field | Asks the question, and hands the keyboard to the pane's own field |
| ⏎ in the pane's field | Asks the next question |
| Esc while an answer is being written | Stops 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 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.
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:
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.
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 file | Rows it adds |
|---|---|
| Pictures | Dimensions, resolution, colour space, depth |
| Photographs | Camera, exposure (1/125 s · f/2.8 · ISO 100), focal length |
| Sound and video | Duration, sample rate, channels, bit rate, codecs |
| Documents | Pages, title, authors |
| Music | Album, 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:
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.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.
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.
/, no shadowing an existing entry — including broken symlinks), and a
case-only rename is recognised as such on case-insensitive volumes by
comparing inodes.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.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:
--
placeholders down the list.-- 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.
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:
PNG image, Markdown document,
Folder, Alias), from a table of common extensions with an
XYZ file fallback.--;
their size is not computed because that means walking the whole subtree.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.
The filter has two reaches, chosen by the toggle beside it:
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:
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.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.
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.
Four tools, all read-only:
| Tool | What it does |
|---|---|
list_files | Lists one folder at or below the one on screen |
search_files | Finds names containing some text, anywhere below it |
read_file | Reads the first 32 KB of a text file in it |
view_image | Looks 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.
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.
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.model | assistant.mmproj | |
|---|---|---|
Qwen/Qwen3-VL-4B-Instruct-GGUF:Qwen3VL-4B-Instruct-Q4_K_M.gguf | mmproj-Qwen3VL-4B-Instruct-F16.gguf | the default, ~3.3 GB |
Qwen/Qwen3-VL-8B-Instruct-GGUF:Qwen3VL-8B-Instruct-Q4_K_M.gguf | mmproj-Qwen3VL-8B-Instruct-F16.gguf | sees better, ~6.2 GB |
ggml-org/gemma-3-4b-it-GGUF:gemma-3-4b-it-Q4_K_M.gguf | mmproj-model-f16.gguf | another 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.gguf | mmproj-GLM-4.5V-Q8_0.gguf | if 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.
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.
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.
| Colour | Answers | Drawn as |
|---|---|---|
#f5a623 | where is the keyboard? | a 2pt bar along the focused region's top edge |
#0a84ff | which 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).
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.
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.
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.
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.
theme::apply registers the
same visuals for both egui themes and a macOS appearance switch changes
nothing.| Concern | Choice | Why |
|---|---|---|
| GUI | eframe / egui 0.35 | Pure-Rust, no JS toolchain; egui_extras::TableBuilder fits a column-based file list |
| Markdown | egui_commonmark | Renders the assistant's answers; takes its colours from ui.visuals(), so the palette stays in charge |
| Vision | llama-cpp-2's mtmd | Loads a projector and turns a picture into embeddings the text model attends to |
| Directory watching | notify | Wraps macOS FSEvents |
| Deleting | trash | Moves to the macOS Trash rather than unlinking |
| Opening files | opener | Hands a path to the default application, like open(1) |
| Standard folders | dirs | Home, Documents, Downloads, … |
| File icons | objc2-app-kit | NSWorkspace icons, rasterized via NSBitmapImageRep — deliberately not TIFFRepresentation, whose ImageIO writer can crash at launch |
| Errors | anyhow + thiserror | anyhow at the app boundary, typed errors in filesystem modules |
| Logging | tracing | RUST_LOG=tilde=debug to raise the level |
| Agent loop | rig (rig-core + rig-agent) | Tool registry, JSON-schema tool definitions, and the multi-turn dispatch loop |
| Inference | llama-cpp-2 | GGUF in-process; turns Metal on by itself on Apple silicon |
| Weights | hf-hub | Fetches the model from Hugging Face on first use, with progress |
| Async | tokio | One current-thread runtime, purely to block on rig's async loop |
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.
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.
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.
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.
TableBuilder::body(…).rows(…)), so only
rows scrolled into view are built and a directory of thousands stays cheap.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.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.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.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.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.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.
MIT
46 commits
1 commits
Rust
100.0%