Intent: this file captures implementation learnings, fragile behaviors, and regressions to avoid while changing the codebase. It is not a feature spec and not a session log.
See also:
CLAUDE.mdfor contributor workflow, build rules, and subsystem mapSPEC.mdfor intended product behavior
-
Hotkey parsing currently exists in two places.
src/utils.ccontains the richer user-facing shortcut parser with aliases, case-insensitive matching, and typo suggestions.src/hotkeys.cstill contains the X11 grab parser used during actualXGrabKeyregistration. If shortcut syntax changes, keep both paths aligned or consolidate them deliberately. -
Visible-window hotkey handling is a special path. Pressing a global hotkey while cofi is already visible must not behave like a fresh open-from-hidden flow. The visible path cancels focus-loss timers and may switch tabs or step selection inline.
-
X11 grabs can cause synthetic focus events. A global hotkey can trigger
FocusOut(NotifyGrab)on the cofi window. If the focus-loss close timer is left running, cofi may close incorrectly while the user is interacting with it. Preserve the timer-cancel behavior in the visible-window hotkey path. -
Repeated
Alt+Tabwhile visible is intentional. In Windows mode, repeatedAlt+Tabadvances deeper through MRU history. Do not change this to a noop or reopen behavior unless that is a deliberate product decision. -
Hotkey config changes must re-grab live. Editing, adding, or removing a hotkey is not complete until grabs are refreshed with
regrab_hotkeys(). Saving config without re-registering leaves runtime behavior stale until restart. -
Overlay-driven hotkey changes must refresh list state explicitly. Do not rely on re-setting identical
GtkEntrytext to triggerchangedand rebuild the Hotkeys tab. GTK may treat that as a no-op, leaving the filtered list stale until the user changes tabs or filter text. -
After overlay add/edit, refresh in the full UI-state order. Use
filter_hotkeys()first, thenvalidate_selection(), thenupdate_scroll_position(), thenupdate_display(). If scroll position is not updated after moving selection, the selected binding may be off-screen in long lists. -
Overlay code may need direct access to filter helpers. If a modal add/edit flow changes tab data, the relevant
filter_*()function cannot be treated purely as a local implementation detail in the main event loop. Keep that boundary practical enough for overlays to rebuild visible state immediately. -
Startup hotkey conflicts are a first-class flow. Grab failure is not just an exceptional log line; the Retry/Exit dialog is part of normal startup/error handling. Changes to hotkey setup should preserve conflict reporting quality.
-
The
!suffix changes dispatch mode. Hotkey commands ending in!auto-execute immediately. Commands without!prefill command mode for editing. Changes here must be checked both when cofi is hidden and when it is already visible.
-
Keep side effects out of low-level movement helpers. The
:tmregression came frommove_window_to_next_monitor()containing UI lifecycle behavior (gtk_main_quit()) that belonged in the command layer. Helpers should move windows; command handlers should decide activation, hiding, quitting, and follow-up UI behavior. -
Avoid duplicate activation paths.
cmd_toggle_monitoralready activates the commanded window after moving it. Reintroducing activation inside the lower-level move helper can cause double-activation or control-flow bugs.
-
Provider key hooks must be verified through the focused entry path. Unit tests that call
CofiTabProvider.handle_keyoron_enter_presseddirectly prove the hook logic, but not GTK delivery. Keys such asReturnandCtrl+Ecan be consumed byGtkEntrydefaults unless the livekey-press-eventwiring is covered by an Xvfb test. Handlers must not mutate state on the path toreturn FALSE, because the same event can be seen by both the focused entry and the window. -
Provider rendering is inverted like the rest of cofi's list UI.
format_provider_displayrenders fromend - 1down toscroll. Do not "fix" provider display to render raw index 0 at the top unless the entire display pipeline changes with it. -
Keep raw provider rows separate from filtered/visible rows. Provider row callbacks (
format_row,match_string,row_identity,slot_payload_for) take raw provider indices. Core selection uses filtered indices and maps them throughcofi_filtered_to_raw(). Mixing these works on unfiltered lists and fails once query filtering or scoring changes row order. -
on_enter_pressedintentionally receives both filtered and raw indices. The filtered index is useful for UI state; the raw index is the provider's stable row lookup. Ported providers should not recover raw indices manually when core already supplies them. -
Provider tabs should use provider-owned hooks for tab behavior. Tab-specific key handling belongs on
CofiTabProvider.handle_key; list lifecycle belongs onon_enter,on_leave, andon_query_changed. Reintroducing provider cases inkey_handler.c,selection.c, ordisplay.cis usually architecture drift. -
Provider key hooks get first refusal before generic provider slots. A slottable provider may still need tab-local shortcuts such as Projects
Ctrl+N.handle_keyshould returnTRUEonly for keys it owns; returningFALSEletsCtrl+key/Alt+keycontinue into generic slot assignment and recall. -
Dynamic provider tabs are not
TAB_COUNT.TAB_COUNTis the sentinel for the core tab enum. Runtime provider handles start after it. Use registry helpers such ascofi_list_provider_tabs()instead of looping fromTAB_WINDOWStoTAB_COUNTwhen you mean "all visible provider tabs." -
Provider enablement is stronger than hiding a tab. A disabled provider must fail closed across tab lookup, command lookup, prefix lookup, command candidates/help, and slots. Do not use
tab_visibilityas the disable gate;show_all_tabsintentionally overrides hidden visibility but must not resurrect disabled providers. -
Header changes require stale-daemon awareness. Large
AppDatalayout changes shift offsets used by the running daemon. After changingAppData, provider state structs, or tab visibility arrays, rebuild and restart before judging behavior; otherwise the old daemon can read garbage and produce unrelated-looking failures.
-
format_provider_displaymust subtract provider tail rows frommax_linesbefore rendering content.shortcut_hintconsumes 2 lines (blank spacer + hint) and Projects consumes 1 extra status-spacer line. Rendering content at the full budget and appending the tail afterward overflows the fixed popup height. -
src/ui/display.chas explicitstrcmp(p->id, "projects") == 0coupling for Projects tail rendering. One guard appends the Projects status spacer; the other includes that spacer in the provider tail-line budget. A new provider that renders its own status spacer needs a parallel guard. If a third provider needs this, promote the concept to aCofiTabProvider.extra_tail_linesfield instead of adding more ad hoc string checks. -
Preserve selection before model-state mutation. Providers must call
preserve_selection()BEFORE wiping or replacing the data structures that backrow_identity(); otherwise restore falls back even when the same logical row still exists.src/bluetooth/bluetooth_model.capply_snapshot()is the working example. Selection drift or jumps to row 0 are the symptom of getting this wrong. -
Stable model sort must happen before refilter. Providers that refresh external data and preserve selection by identity need a deterministic model order before any filtered-index rebuild. Bluetooth uses alias-ascending with path tiebreak; without that sort, refresh ticks can reshuffle rows under a stable identity-preserved selection.
-
Refilter after row delete before selection validation. If a provider deletes a raw row, it must rebuild its filtered list before
validate_selection(). Otherwise the old filtered index can point at a now-missing raw slot and the UI shows(missing)until another refresh. Reference:fe816a7. -
No sync D-Bus calls in
src/bluetooth/. All Bluetooth D-Bus work must go throughg_dbus_connection_call()async with explicittimeout_msec._syncvariants are forbidden in this subsystem because cofi must never block on Bluetooth operations. -
src/system_actions/system_actions.cis the anti-pattern for new D-Bus work. It usesg_dbus_proxy_call_syncand related blocking helpers throughout. Do not copy from it. Scaffold async D-Bus work fromsrc/bluetooth/or the async result patterns insrc/projects/instead.
-
command_target_idmust be captured beforeshow_window()when entering command mode from hidden/delegated flows. Required ordering isget_active_window_id()→show_window()→enter_command_mode(). -
Daemon-socket dispatch preserves this ordering for
--command/ command opcode paths. Hidden hotkey command-mode dispatch follows the same sequence. -
Regression risk: if
show_window()is moved before capture, command targeting can drift to the wrong window. TFD-511 behavior depends on identity pinning from the pre-show active window, not post-open selection side effects.
-
Treat
SPEC.mdas the behavior spec. It is the best source for intended user-visible behavior. -
Treat
COFI_PRD.mdas partially stale unless refreshed. It still mentions old architecture such as D-Bus-based single-instance behavior. Verify against code and recent commits before relying on it.
-
Title preservation across window-list refresh matters.
get_window_list()must not overwrite cached titles for existing windows. For known windows,handle_window_title_change()is the sole authority for title updates; client-list scans should read titles only for newly discovered window IDs. Reference:4ec2ce9. -
Geom restore matches by current title, not bound X11 id. Restore that treats
bound_x11_idas the primary lookup breaks when a restarted window comes back with a new X11 id. The stable path matches by current title plus class/instance/type anchors, with the bound X11 id used only as a disambiguation hint among otherwise identical candidates. Reference:b162a22.
-
Fixed width is enforced at the final text-buffer boundary. Provider column hints are still required for well-formed rows, but they are not the only guard. Any content passed to
gtk_text_buffer_set_text()must already be clipped toget_display_columns(app)line by line; otherwise GTK can expand the toplevel horizontally for a long unwrapped line. -
fixed_window_size_initializingflag is cleared viag_idle_addaftergtk_window_resize()ininit_fixed_window_size().gtk_window_resize()is async — the resize-triggeredsize-allocateon the window (which fireson_window_size_allocate) may arrive after the idle clears the flag, letting the reposition callback run during init. In practice the reposition is idempotent so this is low impact, but if you touch the fixed sizing init path, be aware of this ordering hazard. A cleaner fix would compare the current allocation against the target size insideon_window_size_allocaterather than relying on the flag.
-
Digit slots are intentionally stricter than "any visible pixel." The design target is "reachable by eye": only meaningfully visible windows should receive Alt+1-9 workspace slots.
-
Occlusion uses rectangle subtraction, not summed overlap. Do not go back to accumulating occluder overlap percentages; overlapping occluders double-count and can report impossible values like >100% occluded.
-
Use outer/frame geometry for subtraction, but content rects for visibility. Shrinking windows to content rects before subtraction caused regressions. The correct rule is: subtract using full window geometry, then clip surviving fragments to the target window's content rect before counting visible area.
-
Decoration leaks must not count as visible content. Frame extents are used so titlebars/borders do not keep a heavily covered window eligible for a digit slot.
-
Total visible scraps are not enough by themselves. A window must clear the configured threshold both for total visible content fraction and for its largest single visible content fragment. This prevents fragmented leftovers from qualifying.
-
Thin slivers should not receive digit slots. The largest visible fragment must also satisfy the minimum visible dimension gate (currently 8x8 px).
-
Slot overlays should track the meaningful visible area. Place the overlay at the center of the largest visible fragment, not the raw window center and not a weighted average across multiple fragments.
-
slot_occlusion_thresholdis an integer percent.5means 5 percent. Keep legacy float config compatibility in the loader, but write/save the modern integer-percent form.
-
unmaximize_and_settle()is mandatory before geometry changes on maximized windows. Clearing maximize atoms is asynchronous at the WM boundary. If code sends_NET_WM_STATE_REMOVEand immediately callsxmove_resize_frame_aware()orXMoveResizeWindow(), the WM can still treat the window as maximized and override or ignore the new geometry. Always useunmaximize_and_settle(), not bareset_window_maximized(..., UNSET). Reference:45e009f. -
CSD
_GTK_FRAME_EXTENTShandling is required for tiling and geometry work. CSD windows often have no_NET_FRAME_EXTENTS, while rawXGetGeometrystill includes invisible shadow margins. Tiling and geometry code must check_GTK_FRAME_EXTENTSwhen_NET_FRAME_EXTENTSis absent or all-zero; otherwise the visible content frame is placed incorrectly in the work area. References:26c4875for tiling,da4283efor slot assignment. -
Tiling fills the assigned region; cofi clears
WM_NORMAL_HINTS.PResizeIncon target windows before resize so the WM (notably marco) cannot re-apply increment snap. Clients that re-publishWM_NORMAL_HINTSafter tile (on font change, zoom, etc.) will get snap behavior back until next tile. Mouse-drag resize on tiled windows no longer cell-snaps as a side effect — accepted. Min/max constraints stay enforced on cofi's side. Without this hint clear, the WM re-snaps and produces variable bottom gaps up toincrement - 1pixels across windows with different increments, such as 40px vs 48px terminal rows on the same:tr4. Reference:TBD-commit-sha. -
_NET_FRAME_EXTENTSPropertyNotify can arrive before marco's first-map cascade settles. If restore runs synchronously at that point, the frame-aware correction reads marco's cascaded position (typically about+10,+14px), overcorrects into negative coordinates, and marco clamps the result, leaving a visible top gap. The 150msg_timeout_adddelay insrc/x11/frame_extents_restore.cwas empirically verified as sufficient. It is not a generic "wait for X11" budget; reducing it re-exposes the race.
-
Repeat-last-action is intentionally narrow in v1. It applies only on the Windows tab, only for the current session, and only when
.is pressed with an empty query. -
Repeat stores queries, not window identities. The saved state is the last successful non-empty windows-tab query-driven activation. Replay must re-filter the live list and activate the current top match, not a stale window ID.
-
Empty-query Enter must not overwrite repeat state. Storing
""turns repeat into a generic "activate current top window" shortcut, which is explicitly out of scope for v1. -
.must insert normally when the query is non-empty. The repeat intercept only applies to the Windows tab with an empty entry.
-
Do not concatenate
name,generic_name, andkeywordsinto one search string.has_matchis a subsequence check; concatenation creates cross-field false positives. Example:audaccan match Atril via characters spread across multiple fields. Match each field independently instead. -
Do not treat the keywords field as one big string either. Space-joined keywords let subsequence matching hop across unrelated tokens. Example:
thucan match Audacity via letters spread across separate keyword words. Split keywords into tokens and match each token independently. -
Apps ranking is local to the Apps launcher. Do not "fix" Apps behavior by changing the shared Windows-tab fuzzy/MRU ranking pipeline.
-
These invariants are regression-tested in
test/test_apps.c.
-
Do not assume all test entrypoints cover the same set.
make testandtest/run_tests.share not guaranteed to stay in sync. Check both before assuming a test is part of the default suite. -
Header changes still require a clean rebuild discipline. The Makefile now has generated header deps, but the repo workflow still expects
make clean && makeafter header changes.
-
Be careful with hidden-vs-visible state. A large share of regressions come from code that works when cofi is hidden but behaves differently when the window is already open.
-
Prefer current code plus recent commits over older prose. When docs disagree, trust the implementation,
SPEC.md, and the newest relevant commits first. -
check_rule_match()must getallow_fire=falsefor non-firing scans.allow_fire=truemutates per-window transition state. Callers that are only seeding state, such as startup client-list dispatch over already-existing windows, must passallow_fire=falseoronce=truerules can be consumed before they ever legitimately fire. Reference:e2f9856.
-
Basename dedupe: first in $PATH wins. When the same binary name appears in multiple PATH directories, only the first (highest-priority) entry is kept. No
.desktop-style shadow or merge is applied. -
Cache cap is MAX_PATH_BINS (4096). A single overflow warning is emitted per scan; later PATH entries are silently dropped. Tested by
test_global_cap_overflow_sets_warned. -
Filter output cap is MAX_APPS (512), applied at copy-out — NOT during scoring. Score ALL substring-matching entries first (into
scored[MAX_PATH_BINS]), sort by score descending, then truncate to MAX_APPS. Applying the cap during the scoring loop drops high-score entries that appear late in the alphabetically-sorted cache when more than 512 entries match. (Was the audit-batch-b bug; regression-tested bytest_large_match_set_prefers_high_score.) -
GFileMonitor cap is MAX_PATH_MONITORS (64) PATH directories. Defined in
src/path/path_binaries.h. If this needs to become configurable, change the constant there. -
$is a Path-tab prefix, not an Apps submode. Prefix routing claims$through the Path provider registration. Do not reintroduce$stripping or path-filter dispatch inside Apps; that recreates the split-brain routing bug this subsystem split removed.
-
detach_launch_properlytries systemd-run first. It probes forsystemd-run, builds["systemd-run", "--user", "--scope", "--", ...]and spawns. If unavailable or spawn fails, it falls back to fork + setsid + double-fork + execvp. -
The errno-pipe is the correct exec-failure propagation mechanism. The double-fork makes the grandchild's exit status invisible to the original parent. The errno-pipe (
pipe()+FD_CLOEXECon the write end) solves this: a successfulexecvpcloses the write end automatically; failure writes errno bytes that the parent reads. An empty read is success. -
Terminal launches are not generic detached app launches. Commands that run inside a terminal must use a user-scope
systemd-runwhen available, but the terminal emulator process must not have stdin/stdout/stderr redirected to/dev/null. The user scope keeps terminal clients outsidecofi.service's cgroup so they survive cofi restarts; normal stdio keeps interactive tools such as Claude, Codex, tmux, and zellij able to create and use their PTY. The fork+setsid fallback is best-effort only and may still die with service cgroup cleanup. -
Terminal launches always keep the explicit
sh -cwrapper. Argv-split terminals use{term, -e, sh, -c, cmd}. Mate/GNOME terminals use the modern{term, --, sh, -c, cmd}form instead of deprecated-e. Without the shell wrapper, multi-word commands are mishandled on argv-split terminals. -
Projects tool resolution must be shared across list and action paths.
tmux,zellij,zoxide, and the configured file explorer are resolved from Projects config first, then the cofi servicePATH. Do not hardcode user/mise paths or update onlyprojects_refresh.c; attach/new/kill/rename and existing-window activation must use the same resolved executable. -
plocate results are only as fresh as the last
updatedbrun. The Projects locate fallback reads the system locate index, not the live filesystem. Folders created after the last index update will not appear untilupdatedbrefreshes that index. -
plocate exit code 1 means "no matches," not failure. The Projects locate fallback must treat exit status
1as an empty result set and return quietly. Logging or surfacing it as an error regresses normal zero-hit queries. -
Projects locate queries are converted to basename globs before they reach plocate. The locate subprocess runs with
-b, so cofi must interleave*between query characters before invoking plocate or word-initial queries likebhwill stop matching multi-word basenames such asba-host. Do not revert this to a literal substring lookup. -
Primary project rows get a fixed ranking bonus over locate rows. The merged Projects list intentionally favors tmux, zellij, and zoxide hits over locate discoveries. Without that bonus, fzf's clean-prefix preference can rank locate filesystem finds above the user's recent or frequent primary entries. Do not drop the bonus without redesigning the merge policy.
-
:fileshonors.gitignoreand.fdignoreby default. The Files tab intentionally letsfdapply ignore rules, so build outputs and dependency trees disappear without extra cofi logic. If an expected file is missing, check the repository's ignore files first. -
GUI desktop entries must not go through a shell.
Exec=is argv-like, not shell syntax. The correct path isg_shell_parse_argv(handles quoting/escaping only) followed bydetach_launch_argv_array(direct execvp). Shell metacharacters inExec=must not execute. -
Do not use
GSubprocessLauncherfor app launches. It inherits cofi's cgroup. Launched apps then die when cofi's cgroup is cleaned up (e.g.systemctl --user stop cofi). This is the root cause of TFD-557.
-
Detection priority chain:
$TERMINAL(if resolvable in PATH) → desktop-env configured terminal →x-terminal-emulator→ hardcoded candidates →xtermfallback. -
x-terminal-emulatoris NOT always the right answer. It is the Debian alternatives-system pointer and may point to an unexpected terminal. On MATE/GNOME/Cinnamon sessions the correct approach isgsettings get org.gnome.desktop.default-applications.terminal exec. On XFCE usexfconf-query. On KDE fall back tokonsoledirectly. -
The desktop-env step is injectable for tests. The
DesktopTerminalGettertypedef allows test stubs to simulate specific desktop sessions without needing a livegsettingsorxfconf-query. Tests usedetect_terminal_with_desktop_for_test(resolver, getter). Keep the getter parameter in any refactor ofdetect_terminal_with_resolver.
-
No caching, no
inotifyacross enters. The Bookmarks tab re-reads~/.config/google-chrome/<profile>/Bookmarksfor every profile on every tab entry by design (TFD-857). Resist adding a cache or file-watcher; the file is small, parsing is fast, and skipping live refresh keeps the subsystem stateless. If a future contributor proposes caching here, weigh the cost of staleness against the cost of the re-read first. -
Bookmark slot payloads are colon-delimited but the URL itself may contain arbitrary colons. Payloads use the format
bookmark:chrome:<profile_dir>:<url>, where the URL may include://and query fragments such asfoo:bar. Parsers must take the remainder after the third colon as the URL. Splitting on the fourth or later colon corrupts recalled bookmarks.