apex · updated October 8, 2026 · 7507b874a9b6

Pickers and Overlays

Relevant source files

Besides acme's tiled windows, the apex client draws a set of short-lived overlays. They are list pickers and one-line fields that take the keyboard for a moment and then go away. There are six of them: the ⌘P finder (and the same finder across all tabs), ⌘O quick open, the ⌘⇧P command palette, ^F inline file-name completion, the editing and folder picker on a window tag's head, and the folder picker under the title bar's working-directory crumbs. The session selector (⌘T) is built from the same parts but belongs on Sessions, Tabs and Window Chrome.

None of these overlays is replicated state. Each is an Option<…> field on the Acme app (see The UI Client), and none of them shows up in the log until the user picks something. A pick then goes through the normal paths: a Proposal::Goto, Acme::goto, Acme::execute (B2), apex_server::perform with SetPath/SetLabel, or a cd. Some overlays get their contents from the host. Quick open streams results from a find job, and the folder pickers and ^F use the server's candidates listing. That way a remote session's file system is listed where it lives (see The Server and The Attach Protocol). Mouse and keyboard handling outside the overlays is covered on Mouse, Keyboard and Look.

The overlays at a glance

Overlay Opened by State field / type Items come from Matcher A pick does
Finder ⌘P (Goto) finder: Option<Finder> open windows in layout order, plus files closed lately apex_core::fuzzy (via finder::score) Proposal::Goto by window id or path, switching tab first if needed
Finder, all tabs ⌘⇧O (GotoAll) same, all: true every tab's replica (the current one and parked ones) same as above
Quick open ⌘O (OpenPath) quick: Option<QuickOpen> host-side find::Job walk of the session's cwd apex_core::fuzzy, run on the host Acme::goto on the file, or the folder with its slash
Command palette ⌘⇧P (Commands) commands: Option<Commands> window verbs, tag words, rule verbs, recent execs, plumb::BUILTINS commands::score Acme::execute as B2 in the target window
^F completion ^F or Insert in text completion: Option<Completion> Server::candidates for the fragment prefix (starts_with) replaces the name, adding / after a folder or a space after a file
Tag field double-click on path or label tag_edit: Option<TagEdit> none none Proposal::SetPath / SetLabel
Tag path picker click on a folder or name in the tag picker: Option<tagedit::Picker> associated windows plus candidates folder listing apex_core::fuzzy goto window or entry; ⌥↩ replaces in place
cwd picker click on a title-bar crumb cwd_picker: Option<CwdPicker> candidates listing, folders only apex_core::fuzzy Acme::cd

The key bindings are in shell::bindings: cmd-p → Goto, cmd-shift-o → GotoAll, cmd-o → OpenPath, cmd-shift-p → Commands (shell.rs:258-314). main.rs connects these actions to open_finder(false), open_finder(true), open_quick and open_commands (main.rs:164-173). The module comment at the top of finder.rs still calls the all-tabs finder ⌘⇧P. That comment is out of date: the binding is ⌘⇧O, and ⌘⇧P opens the command palette.

Sources: shell.rs:258-314, main.rs:164-173, finder.rs:1-18, app.rs:4371-4375

Shared building blocks

flowchart TD
    LE["field::LineEdit"] --> FV["field::field_view"]
    FV --> F["Finder"]
    FV --> Q["QuickOpen"]
    FV --> C["Commands"]
    FV --> T["TagEdit"]
    FV --> CW["CwdPicker"]
    LE --> P["tagedit::Picker (typed into the tag)"]
    PAL["shell::palette_place / palette_panel / palette_field / palette_row"] --> F
    PAL --> Q
    PAL --> C
    CH["shell::chosen"] --> PAL
    CH --> P
    CH --> CW
    CH --> CO["Completion"]
    FZ["apex_core::fuzzy"] --> F
    FZ --> P
    FZ --> CW
    FZ --> HOST["find::Job on the host"]
    HOST --> Q
    CAND["Server::candidates"] --> P
    CAND --> CW
    CAND --> CO

The one-line field: LineEdit

field::LineEdit is the text field used by every overlay. It holds a String, a cursor and an optional anchor (both counted in characters, not bytes), plus an undo stack of (text, cursor) pairs capped at 64 (field.rs:10-18, field.rs:81-86). Because it implements Deref<Target = str>, callers can use filter.trim() and filter.is_empty() directly.

LineEdit::key(key, ch, mods) -> Edited is the editing state machine. It handles the keys a Mac text field answers:

It returns Changed, Moved or No (field.rs:202-312). Each overlay's key handler relies on this result: Changed re-filters and usually resets the cursor to 0, Moved only redraws, and No lets the key fall through. Word boundaries are alphanumerics plus _ (field.rs:136-164).

field_view(e, caret_on, hint, active) draws the field. It splits the text at the cursor and the selection ends, gives the selected span the field_sel background, puts a 1.5 px caret at the cursor, and shows the dimmed hint when the field is empty (field.rs:315-349).

The palette look and the "chosen" row

Three overlays (the finder, quick open and the command palette) plus the session selector are drawn as a palette in the style of Manifold's. palette_place puts the panel across a translucent scrim (veil()), 30% of the way down the window. palette_panel is a 560 px card with 12 px rounded corners. palette_field is a 48 px search row with a magnifying glass. palette_row is a 34 px row (shell.rs:699-797).

The anchored pickers (tag path, cwd and ^F) build their own panels, but all of them use shell::chosen to mark the selected row. chosen colours the row by the mouse button whose action it stands for. Act::Exec uses B2's sweep colours and is used by the command palette and the B4 menu. Act::Look uses B3's and is used by the finder, quick open and the pickers. In both cases a small bar is drawn at the row's left (shell.rs:745-785).

Every panel also adds self.overlay_mark(). This is a zero-size canvas that records the panel's bounds in overlay_bounds for the current frame (app.rs:1131-1154). Those bounds are used for two things: cutting holes in the native web views so the overlay shows above a page, and over_overlay, which makes the pointer over a panel count as being on no text. Keys then go to kept_target instead of to whatever is under the panel (app.rs:3140-3156).

Blinking

Each overlay runs its own blink. The finder and the selector spawn a loop on shell::BLINK (500 ms). Quick open polls every 40 ms and redraws when the caret changes phase. The tag and cwd pickers loop at 530 ms. Commands::caret_on calculates a 530 ms phase but spawns no timer of its own, so its caret only blinks when something else causes a redraw (finder.rs:234-257, quickopen.rs:101-122, tagedit.rs:116-118, commands.rs:95-97).

Sources: field.rs:1-349, shell.rs:699-797, app.rs:1131-1154, app.rs:3140-3156

Routing keys, clicks and the Edit menu

The overlays have no common type. Acme's key handler checks them one at a time, in a fixed order, and gives the key to the first one that is up (app.rs:4918-4972):

  1. completion, but only for keys without ⌘, and only if completion_key returns true;
  2. finder;
  3. quick, unless the key is ⌘O, which falls through so OpenPath can toggle the overlay closed;
  4. url_edit (the page address bar, see The I/O Plane and Pages);
  5. tag_edit;
  6. picker (the tag path picker);
  7. cwd_picker;
  8. session_edit, then session_menu (escape only);
  9. commands;
  10. selector.

Any key that reaches the text afterwards is followed by completion_follow (app.rs:5045).

A mouse press anywhere closes them, again in a fixed order. url_edit, tag_edit, picker, cwd_picker and completion are dropped and the press goes on as usual. A press that closes commands, finder, quick or selector is used up by the close and does nothing else (app.rs:3301-3335). Clicks on rows still work because each panel calls stop_propagation on its own mouse-down, so the press never reaches mouse_down.

The Edit menu's actions (⌘X ⌘C ⌘V ⌘A ⌘Z) arrive as actions before any key event. menu_edit sends them to overlay_edit when the selector, finder, url field, palette or quick open is up (app.rs:5109-5115). overlay_edit asks overlay_field() for the field that has the keyboard. It checks the selector (or its new-host form), then url_edit, commands, finder and quick, and applies paste (first line of the clipboard, trimmed), select-all, copy, cut or undo to that field. If the text changed, it calls overlay_changed, which resets the list (shell.rs:1412-1494). The tag field and the two folder pickers are missing from both checks, which the review below points out.

Sources: app.rs:4918-5047, app.rs:3287-3335, app.rs:5063-5115, shell.rs:1412-1494

⌘P: the finder

Finder holds a LineEdit filter, a cursor, its entries, the all flag and here, the tab the window shows (finder.rs:76-86). Entries are gathered once, when the finder opens, by session_entries (finder.rs:172-204):

With all set, finder_entries does the same for every tab in Pool::tabs. It uses this window's node for the current tab and the parked Node replica for each other tab. Every entry gets tab: Some((TabId, label)), and tab_label appends @ host for remote sessions (finder.rs:206-232).

Ranking

Finder::picks (finder.rs:93-114):

finder::score passes through to apex_core::fuzzy::score, the Zed-style scorer that the host's ⌘O listing also uses. Every query character must appear in order. Each scores 1.0 at the start of the file name or when it continues the previous match, 0.9 after /, 0.8 at a word start, and 0.55 otherwise. A case mismatch halves the score, and matches inside the file name get a bonus (fuzzy.rs:1-14, finder.rs:125-135).

↑ and ↓ wrap around (rem_euclid). Typing resets the cursor to 0 (finder.rs:116-122, finder.rs:264-293).

Picking

Acme::pick goes to an open window by its id, not its path, because a file and its preview share a path. If the entry is in another tab, it calls switch_to first. If that tab is still attaching, the Loc is kept in pending_goto and used once the tab arrives. Otherwise it applies Proposal::Goto { loc } through apex_server::proposal::apply, which records the origin on the back stack, as a jump should (finder.rs:295-323; see Proposals).

Recently closed files

track_closed runs on every sync. It compares the file windows (not scratch ones) with those seen last time. When an absolute, non-directory file window has disappeared, it calls note_closed. It resets its baseline whenever the window's session URL changes, so switching tabs does not count as closing (finder.rs:325-349). note_closed keeps the 50 most recent paths per session, newest first and without duplicates, in ~/Library/Application Support/apex/recent-files, one url<TAB>path per line. That makes the list per machine, not per replica (finder.rs:137-168).

The panel marks each row: ▶ for an open terminal, ● for another open window, ○ for a closed file. It shows the label as a chip, the folder in the dim ink, "closed" for closed files, and a tab badge reading "· here" for the current tab. At most 24 rows are drawn (finder.rs:351-433).

Sources: finder.rs:1-433, fuzzy.rs:1-14

⌘O: quick open, backed by the host's find job

Quick open lists everything under the session's directory (meta.cwd) and matches it as the user types. The matching runs on the host, where the files are. The client only receives the best page of results.

sequenceDiagram
    participant U as "User"
    participant A as "Acme (quickopen.rs)"
    participant L as "Link / in-process queue"
    participant J as "find::Job (host)"
    U->>A: "⌘O"
    A->>L: "FindStart {id, dir: cwd}"
    L->>J: "Job::start (walk thread)"
    A->>L: "FindQuery {id, gen 1, query '', limit 200}"
    loop "every 40 ms while open"
        J-->>L: "Found {id, gen, items, matched, indexed, done, capped}"
        A->>L: "quick_take: newest gen in [shown, gen]"
    end
    U->>A: "types a key"
    A->>L: "FindQuery {gen+1, query, limit 200}"
    U->>A: "cursor nears end of page"
    A->>L: "FindQuery {same query, limit += 200}"
    U->>A: "Return / Esc / click / ⌘O"
    A->>L: "FindStop {id}"
    L->>J: "job dropped, threads end"

open_quick toggles: if quick open is already up, it closes it. If cwd is empty it shows a notice and stops. Otherwise it closes the finder and palette, assigns a new request id (next_find), and starts the listing:

It then sends the first query and spawns a 40 ms poll that calls quick_take and redraws when something arrived or the caret blinked (quickopen.rs:69-124). The daemon handles FindStart by starting a job whose sink writes straight to the asking connection, FindQuery by calling job.query, and FindStop by dropping the job (daemon.rs:885-902).

On the host, the job walks breadth-first. It skips dot-directories, node_modules, __pycache__ and buck-out, and does not follow directory links. It stops at CAP = 1,000,000 entries and reports that it did. Queries are matched with apex_core::fuzzy across cores, rematched as the walk adds entries, and abandoned partway through when a newer query arrives (find.rs:1-45).

Generations and pages

Each query carries a generation, gen. quick_query(fresh) increments gen. A fresh query resets limit to PAGE (200) and the cursor to 0. A query that is not fresh asks again with a larger limit (quickopen.rs:126-141).

quick_take accepts a Found message only if its id matches and shown <= gen <= q.gen. An answer to an older query can therefore still be shown until the newest one arrives, but never after. A larger page of the same query keeps the cursor where it is, clamped to the new length (quickopen.rs:143-171).

quick_moved asks for another page when more matches exist than have arrived, the cursor is within ROWS (12) of the end, and the previous page came back full (quickopen.rs:183-192). The cursor clamps at both ends instead of wrapping, and PageUp/PageDown move 12 rows.

quick_pick closes the overlay and calls Acme::goto with root + rel, adding a trailing / for a folder so it opens as a directory window (quickopen.rs:231-240). close_quick sends FindStop for a remote job; an in-process job ends when it is dropped. The footer shows "Indexing… N" or "N entries", the match count, and a note when the walk was capped (quickopen.rs:283-306).

Sources: quickopen.rs:1-315, daemon.rs:885-902, find.rs:1-45, remote.rs:442-443

⌘⇧P: the command palette

open_commands toggles. It first finds the target window with command_target: the visible window under last_mouse, or else the window of the last selected text (commands.rs:148-153). It then gathers (text, Origin) items with no duplicates, skipping empty strings and | (commands.rs:100-146):

Origin Glyph Items
Tag › node.window_verbs(w), then each whitespace-separated word of the window's tag
Tool ⚙ plumb::verbs_for(rules, path, kind, window, owner), the verbs the B4 menu offers (see Plumbing)
Recent ↺ up to 40 exec texts from every window's execs and the layout's, newest first by Seq
Apex ▸ apex_core::plumb::BUILTINS

commands::score(text, q) is a separate, simpler matcher. It matches a case-insensitive subsequence and scores +1 per character, +8 at index 0, +4 after a non-alphanumeric, and +5 for a run. The total is multiplied by 10 and reduced by len/4 (commands.rs:57-83). matches() sorts by score, keeps gathering order for ties, and returns at most 12 items.

Return runs the selected item, or the typed text if nothing matches, through Acme::execute(ctx, text), the same path B2 uses (commands.rs:155-184). ctx is ExecCtx::Window(w), or ExecCtx::Top if there was no target. Rows are drawn with Act::Exec colours, and the panel has a higher deferred priority (2) than the finder's (1).

Sources: commands.rs:1-233

^F: inline completion

^F, or the Insert key, in a text view calls Acme::complete(v, q0) (app.rs:5216-5220). This is acme's textcomplete. It walks back from the caret over file-name characters to find the fragment, then asks the server for candidates in the view's directory. A local backend calls server.candidates directly and queues the answer. A remote one sends ClientMsg::Candidates { view, ctx, at: q0, prefix }, and the daemon replies to that connection only (app.rs:4760-4780, daemon.rs:925-929). Server::candidates lists the fragment's directory and returns names that start with the fragment's last part, sorted, each with a flag saying whether it is a directory, or an error string (lib.rs:1235-1246).

Routing listings by at

The folder pickers use the same Candidates request and reply. To tell the replies apart, they put a sentinel in the at field. On each sync, the client routes replies as follows (app.rs:1905-1914):

if c.at == crate::tagedit::LISTING {          // usize::MAX
    self.got_listing(c);                      // tag path picker
} else if c.at == crate::cwdbar::CWD_LISTING { // usize::MAX - 1
    self.got_cwd_listing(c);                  // cwd picker
} else {
    self.got_candidates(c);                   // ^F at caret offset `at`
}

What happens with the answer

got_candidates ignores the reply if the caret has moved since the request. Otherwise (completion.rs:64-93):

While the list is up, typing still goes into the text. completion_follow runs after each key and narrows the list by prefix match on what has been typed since start. It closes the list when that span contains a non-name character, when the caret moves away, or when a / is typed. A directory's contents only come with the next ^F (completion.rs:168-194).

completion_key handles ↑/↓ (wrapping), Return/Tab (take the selected name) and Escape. It refuses keys while the list has not been placed on screen yet, so that Return goes to the text and does not pick a name the user never saw (completion.rs:123-166).

The list is anchored at the screen position of the name's start, taken from the previous frame's layout. completion_anchor retries for up to UNPLACED (4) frames before giving up (completion.rs:196-222). The panel uses the window's own font (mono or proportional), shows at most 8 rows around the cursor, and draws the already-typed part of each name dimmed (completion.rs:224-298).

Sources: completion.rs:1-299, app.rs:1905-1914, app.rs:4760-4780, daemon.rs:925-929, lib.rs:1235-1246

The tag head: path and label fields, and the path picker

A window tag's head is laid out as atoms (text_element::Atom): Dir(k) for each folder in the path, Name, Untitled, Label, Verb(i) and Typed. press_atom decides what a press on each one does (tagedit.rs:166-199):

flowchart LR
    A["press on tag atom"] --> B{"atom / button / clicks"}
    B -->|"B1 or B2 on Verb"| V["run on release (release_atom)"]
    B -->|"B1 x2 on Dir, Name, Untitled; B1 on Untitled"| E["tag_edit_start(Path)"]
    B -->|"B1 x2 on Label"| EL["tag_edit_start(Label)"]
    B -->|"B1 on Dir or Name"| P["open_path_picker"]
    B -->|"B3 on Dir(k) or Name"| L["look(path up to there)"]
    P -->|"second click within DOUBLE (500 ms)"| E

The field

tag_edit_start places a TagEdit over the bounds of the path or label. For the path, it pre-selects the file name without its extension, since that is what a rename usually changes. For the label, it selects everything (tagedit.rs:218-254).

When Return is pressed, tag_edit_key calls apex_server::perform with either Proposal::SetPath { path: absolute_for(w, text) } (an empty path does nothing) or Proposal::SetLabel (an empty label clears it) (tagedit.rs:256-281). absolute_for resolves what was typed the way acme resolves names: absolute paths and URLs are kept, ~/ means home, and anything else is relative to the window's own folder (a directory window's parent) or to its error directory (tagedit.rs:283-305).

The path picker

A single click on a folder or the name opens a picker under it, modelled on VS Code's breadcrumbs. folder_of(path, atom) gives the folder to list and the entry the path passes through, so the cursor starts on that entry (tagedit.rs:148-164). open_path_picker does nothing on URL pages. It works out how many rows fit below the tag (between 3 and 12), positions the panel so its names line up with the path text, and asks the host for the listing with list_folder_as(ViewId::Tag(w), ExecCtx::Window(w), LISTING, dir) (tagedit.rs:307-363).

The picker's query is typed into the tag itself. Head::picking draws the folder followed by the typed text and its caret (text_element.rs:978-985). picker_anchor moves the panel to follow the Typed atom as the user goes into or out of folders (tagedit.rs:519-532).

Picker::picks lists Choice::Window rows first, then Choice::Entry rows (tagedit.rs:120-146). The window rows come from associated(dir, except): the windows open on that folder, or on a file directly in it, that are not a plain file or folder window. That means Errors, previews, terminals and tool panes, ranked Errors first, then previews, then the rest (tagedit.rs:450-483). With nothing typed, entries are sorted folders first and then case-insensitively. With a query, both groups are fuzzy-scored with finder::score and sorted best first.

Keys in picker_key (tagedit.rs:376-406):

Sources: tagedit.rs:1-676, app.rs:3404-3413, text_element.rs:978-985

The cwd picker in the title bar

cwd_bar draws the session's meta.host (dimmed) and meta.cwd as crumbs. crumbs("/a/bc/") returns each part with its slash and the path up to and including it (cwdbar.rs:68-83). titlefit::first_crumb shortens a long path to …/ followed by the later crumbs (cwdbar.rs:100-183). ⌘-click or B3 on a crumb plumbs the path up to that crumb, with look(ExecCtx::Top, upto). A plain click opens the picker.

CwdPicker is close to a copy of the tag path picker, but simpler. It lists folders only (it filters Candidates on the directory flag), and its first row is None, shown as ./ this folder, whenever nothing is typed (cwdbar.rs:29-66, cwdbar.rs:211-221). It asks for listings with list_folder_as(ViewId::Top, ExecCtx::Top, CWD_LISTING, dir). It has the same → / Tab / ← / Backspace navigation as the path picker (cwdbar.rs:223-286). While it is open, the typed query appears after the crumbs.

Return calls Acme::cd(dir), which works like apex cd. A local backend calls server.cd and appends the resulting meta op to the log. A remote one sends ClientMsg::Cd (cwdbar.rs:86-98, cwdbar.rs:254-264).

Sources: cwdbar.rs:1-392

Edge cases and invariants

Sources: completion.rs:67-71, tagedit.rs:365-374, quickopen.rs:153-157, app.rs:4238-4248

The review: one ListPicker, one overlay state

The client section of ARCHITECTURE.md's review counts "about nine" list pickers. These are the six on this page plus the session selector, the title bar's session dropdown and the B4 menu. It notes:

Its proposal is a single ListPicker with:

The two folder pickers would then become one source with different pick actions (ARCHITECTURE.md:279-301).

The review also counts 14 separate Option overlay fields, each with hand-written "is one up?" checks that disagree with each other: overlay_up, the key routing, menu_edit, menu_command and overlay_field. It notes four or more copies of caret blinking at 500 or 530 ms, most of which ignore View ▸ Blink Cursor. A likely consequence, which the review did not confirm by running the app: ⌘C and ⌘V while the tag field, path picker or cwd picker is open go to the text under the pointer. The checks quoted above bear this out, since menu_edit and overlay_field do not list tag_edit, picker or cwd_picker.

The proposed fix is one Option<Overlay>, or a small stack of them, behind a trait with key, field, edit, render and bounds. Together with "one blink clock" and "one navigation path", this is part of the planned client cleanups (ARCHITECTURE.md:303-324, ARCHITECTURE.md:928-935).

Sources: ARCHITECTURE.md:279-324, ARCHITECTURE.md:928-935, shell.rs:1412-1439, app.rs:5109-5115

Previous: Mouse, Keyboard and LookNext: Sessions, Tabs and Window Chrome