agentleFS
MaraniMatiasMaraniMatias/Muster/AGENTS.md

Muster

AGENTS.md2 starsChanged 25 days agoMITView on GitHub

Muster is a native Rust desktop application. The root is a Cargo workspace of two crates: the app and the core. - The toolchain is pinned in rust-toolchain.toml. Use that cargo, not another one that happens to be earlier in PATH. Put the directory returned by dirname "$(rustup which --toolchain 1.97.1 rustc)" first on PATH, then confirm both rustc --version and cargo --version are 1.97.1. - cargo test --workspace covers both crates. cargo clippy --locked --workspace --all-targets -- -D warnings…

  • Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.

    Of the AGENTS.md files we read, the 31,700 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.

  • In this repository4 other files it gives its agents.

The file

We split this AGENTS.md into the rules it gives an agent

9 rules. About 0 are common, 6 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈6

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use the toolkit's semantic button variants consistently: default for ordinary actions, primary only for the commit action in a decision …

    line 84
  2. Distinctive:

    Use one compact geometry system across screens: 4px corners on controls and on the small overlays the toolkit draws (a menu, a popover, a …

    line 92
  3. Distinctive:

    Keep hover, pressed, selected and focus-visible distinct.

    line 104
  4. Distinctive:

    Do not hand-edit it; regenerate.

    line 185
  5. Very specific:

    Every row is flex_none. A flex column will shrink its children to fit, and the rows then stood 21 pixels apart in a tree that scrolls by 26.

    line 451
  6. Very specific:

    Where the range ends was decided by the +: Only the picker moves it, and it is offered from the moment the note opens rather than after a …

    line 539
  7. Distinctive:

    Every location the service names is asked about, bounded by the observation's deadline rather than by a count of locations: a location this …

    line 716
  8. Very specific:

    Every version this project shipped until the first major release was v0.<minor>.<patch>.

    line 942
  9. Distinctive:

    Delete the old path with the change, and leave out a field that exists only to read what an older build wrote.

    line 991

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

AGENTS.md

Muster is a native Rust desktop application. The root is a Cargo workspace of two crates: the app and the core.

Layout

Cargo.toml        the workspace: the app + core, and the [patch.crates-io] table
app/              muster            the app: GPUI Kit, VT grid, keyboard, sidebar, lifecycle
app/src/          the app's source, and the tests that pin its rules
app/assets/       the icon and the fonts the app draws with
core/             muster-core   PTY + workspace shapes + Git discovery. No window, no toolkit.
vendor/           the vendored crates the RustSec audit hash-pins
scripts/          release gate and packaging for both platforms, the RustSec audit and the vendored-crate hash pins
docs/             the migration plan, and what the port has to satisfy

Development and verification

  • The toolchain is pinned in rust-toolchain.toml. Use that cargo, not another one that happens to be earlier in PATH. Put the directory returned by dirname "$(rustup which --toolchain 1.97.1 rustc)" first on PATH, then confirm both rustc --version and cargo --version are 1.97.1.
  • cargo test --workspace covers both crates. cargo clippy --locked --workspace --all-targets -- -D warnings and cargo fmt --all --check are both clean and are expected to stay that way.
  • Ignored performance checks use scripts/perf-native.sh. The PTY tests belong to core, including its core/tests/ instance-lock suite.
  • A focused test: cargo test --locked -p muster-core pty::.
  • The native app uses GPUI's test-support feature for headless UI coverage; app/src/app.rs uses it for the window, focus and close-path cases, app/src/sidebar_tests.rs for the sidebar rows, the worktree dialog and the directory picker, and app/src/titlebar_tests.rs for the title bar: its crumbs, its menus and the window it belongs to.
  • app/src/theme.rs carries its own tests, so the roles are checked without a window. app/src/state_writer.rs carries its own too: what the slot keeps, drops and flushes is decided without one.
  • The native workspace lives in ~/.muster/native-workspace.json, written by core::navigation. It is not the Legacy SQLite database and nothing reads the old one. It holds roots, archived checkout ids, the active checkout, the sidebar width and the review export location. Live sessions and terminal layouts are never written; review notes stay in window memory until exported, except for the notes a review sent, which are captured in ~/.muster/opencode-rounds.json so a sent review can be shown and settled later. That file is app state like the workspace file, under the same lock and the same bounded, non-symlink read. It is also capped, and a cap is not a wall: a write that gets close gives up what no reader can ask for again, which is the prompt of a settled round, and a person can forget delivered reviews from Settings → Forget delivered reviews… (from any window state; the store is one file). The ordinary action keeps SHA-256 fingerprints of canonical note snapshots as id:digest claims, so unchanged notes remain deduplicated with collision resistance, not mathematical certainty. The same confirmation also offers an explicit last-resort reset that removes settled rounds and their claims; unchanged delivered notes can then be sent again. Neither action touches queued, dispatching or unknown rounds. A restart creates a fresh shell in the selected checkout; it never revives processes that are gone. Both files carry no version at all, so a stored shape is the whole contract: it is what it is, a change is made in place, and a file this build cannot read is refused by name rather than read through or migrated.
  • The preferences live in ~/.muster/settings.json, written by core::settings. This is the one store in ~/.muster that reads a file it does not fully know: every field defaults, a key this build does not have is ignored, and a size outside its documented range is clamped rather than refused, because a preference holds no work and the file is deliberately a document a person may open and edit. It carries no colour, no theme and no zoom -- one appearance is dark and app/src/theme.rs is its only authority -- and no review storage, which core::navigation already persists. A file that does not parse is never written over.
  • Linux builds need the system development packages the native crate links against: Vulkan, X11, xcb, XKB (and its X11 half), freetype and fontconfig. See the linux-build job in .github/workflows/ci.yml for the exact list.

Native UI design system

app/src/theme.rs owns the app's dark palette, semantic roles, and common control styling. UI chrome uses the platform UI font; Fira Code is reserved for the terminal, source editor, diff text, and other code/text previews. Do not use Legacy as the authority for native UI appearance. Keep product colors and state colors in the theme instead of adding raw color values at call sites.

Use the toolkit's semantic button variants consistently: default for ordinary actions, primary only for the commit action in a decision area, danger for irreversible/destructive actions, and ghost/text for quiet toolbar or inline actions. Disabled controls stay visibly disabled without changing a primary or destructive action into a misleading neutral action. Native controls and in-app commands use the default arrow cursor; reserve the pointing hand for actual external links.

Use one compact geometry system across screens: 4px corners on controls and on the small overlays the toolkit draws (a menu, a popover, a picker's panel), 8px on the large surfaces over the window (a dialog, a notification, a command palette), a small set of sizes for compact rows/toolbars and regular actions, and consistent input/select heights within a form. The two radii live in the theme — Theme::radius and Theme::radius_lg — so a button, a field and a picker wear the same corner without a call site naming it. Draft preferences use checkboxes rather than switches. Confirmations use GPUI Kit dialogs; native file and directory pickers remain native, and a path typed by hand is the second answer when the native one cannot open. Prefer semantic toolkit sizes and shared helpers over one-off dimensions: a control's height is its size tier, and a number in a call site is a row's own geometry rather than a control's. Keep hover, pressed, selected and focus-visible distinct; a selected row must remain selected while hovered. Selected navigation uses its own surface and text state, not a one-sided accent bar.

Keyboard focus uses the toolkit's thin-focus mode rather than its 3px outer ring: fields tint their border and padded buttons draw a 1px edge or inset line. Unpadded text buttons draw that line outside their bounds, so leave room for it rather than clipping it. Never suppress a control's keyboard focus.

Roles::row_rest and Roles::row_hover are the one place a row's state-to- colour combination is decided. GPUI refines hover after the rest style, so a selected row's hover must preserve its selected treatment.

Not the terminal's palette

crate::terminal::Palette is a separate contract and is not themed. It is what a VT sequence asked for: ANSI indices, OSC colours, an explicit background. The application draws it verbatim, and no role here reaches TerminalHost::resolve_color. There is no terminal preference, no theme preference and no switcher: one appearance, dark, chosen by main.rs before the first window opens.

The terminal grid, editor and diff share one body background: highlight_theme.editor.background is theme.background. Keep terminal palette colors separate from the UI theme; no chrome role reaches TerminalHost::resolve_color.

The code's size, and the height of its rows

A row is the font size times 1.2, and that ratio is the grid's own (app/src/terminal/view.rs). The document's rows are drawn at it rather than at the toolkit's 1.5, which is a quarter taller than the grid's, so a file read beside a terminal is set like the code it sits next to.

The height is the font size times the ratio and not the face's own ascent and descent. A face's line box is already taller than the em it is drawn in — Fira Code's is 1.23 of it — so multiplying that by the ratio again gave every row a quarter of empty space above the glyph in it. A block element, which the face draws across its whole line box, then left a gap above every row it filled, and a bar of blocks read as a row of separate tiles.

The sizes are preferences, and the panel's number is the number the surface is drawn at: the grid's is terminal.fontSize, and the document's and the diff's is editor.fontSize. They are read when the app starts, so a size that changes needs a restart. The diff's own rows are the toolkit's — Diff computes them at 1.6 of the size — and the app cannot change that without forking it.

Syntax: two tables, and why there are two

A grammar's capture is a Legacy meaning spelled its own way. SYNTAX_TOKENS answers the names the toolkit's highlight theme holds a key for; CAPTURE_ALIASES renames the ones it holds no key for (@escape, @namespace, @parameter, @module, …) in each registered query at install time. A capture that reaches neither table is painted in the editor's own ink, so a new grammar's query is not done when it compiles — it is done when every name it emits is in one of the two. theme::tests::every_capture_a_grammar_can_emit_wears_a_legacy_token states the contract from the grammar's side, and every_grammar_has_had_its_captures_renamed fails if the pass missed one.

The toolkit ships no Gherkin grammar, so the one a .feature file is read with is vendored under vendor/ and registered by theme::install, with its query written there against the node names that grammar declares. Its tree is hash-pinned by scripts/patched-gherkin.mjs, like every other vendored crate the audit is asked to trust.

The toolkit ships no SVG grammar either, and SVG is XML: the pinned upstream XML grammar reads it and theme::install registers it under SVG's own name, so the picker says SVG and the file is read as one. The query is the XML one, so the names it emits that this theme holds no key for -- markup and its family, and error -- are answered by CAPTURE_ALIASES.

File icons: their own palette, and their own generator

theme::Ink is the second contract that is not the app's chrome. A file icon is a drawing wearing the colours Catppuccin drew it in, the way a VT sequence wears what it asked for; Roles::ink holds the values, and nothing else in the crate names one.

app/src/file_icons/data.rs is generated by scripts/generate-native-file-icons.mjs, which reads the vendored Catppuccin module rather than a Catppuccin checkout, and carries Catppuccin's MIT notice. Do not hand-edit it; regenerate. The one thing that cannot be copied is colour: GPUI rasterizes an SVG to a single-colour alpha mask, so a drawing that paints with three inks is three layers, each a complete SVG, stacked in one box and tinted per layer. The generator enforces that every layer carries a paint usvg can parse, that every shape lands in exactly one layer, and that no rule key carries a dot — a key with a dot is one no file name can be asked about, which is how Legacy reaches a third fewer rules than the theme defines.

On Linux, the app fills the window

The window manager draws the frame. The app does not ask for client-side decorations on Linux, so the window manager owns the caption and the window's controls. Two reasons, and both are the toolkit's client-side decorations rather than the app's own code: they reserve a twenty-pixel band on every side for the shadow they draw, and that band is a margin around the app — the window's own controls sit twenty pixels away from the corner the pointer goes to, the app is framed rather than filling the window, and the band is gone when the window is tiled, so the app looks different maximized than it does not; and the controls they draw do not answer the click, so minimize, maximize and close are three buttons that do nothing. The band belongs to the decorations and not to the app's own frame, so setting the app's frame to zero was never enough.

Windows keeps the frame the app draws, because server-side decorations would stack a caption and a set of window controls over the bar this window draws for itself.

The native directory picker is the desktop portal on Linux, and a portal is a service the desktop session may not run. The picker is the first answer and the typed path is the second: when the native picker cannot open, the app asks for the path in a dialog of its own, so that adding a directory is never a button that does nothing. The .deb depends on xdg-desktop-portal and xdg-desktop-portal-gtk so that a fresh install has one.

On macOS, the app is the bundle

An executable has no icon of its own on macOS: the icon, the name Finder and the menu bar show, the version in Get Info and the category are what Info.plist says about the .app around it. scripts/bundle-macos.sh is that .app, and it carries Legacy's identifier, name, copyright and artwork so the OS files this build as the same application rather than a second one that looks like it. app/assets/icon.icns is the bundle's icon and icon.png is the same 512×512 the About panel draws; the artwork came out of the old Tauri build and is not hand-edited.

The menu bar is app/src/menus.rs, and it is macOS-only by call site: the same two submenus the Tauri build had. The Edit submenu is not decoration, and it is the one part of the menu that changes behaviour rather than adding it — AppKit answers a key equivalent before the key reaches the window, so ⌘C/⌘V arrive as input::Copy/input::Paste, which is why TerminalView listens for those two as well as answering the keys itself.

LSMinimumSystemVersion 15.0 in that bundle is a claim; .cargo/config.toml is what makes the build enforce it. The two are one decision: if the floor moves, both move, and if a build fails with an unavailable API then that API is the real floor rather than the number that was written down.

Where the Files panel sits

The window has two shapes, and the Files and Changes panel is in one of them. Beside the body, it is a column of its own at the right of the window; under the sidebar, it is the lower half of the sidebar's own column, and the workdirs and their terminals are the upper half.

The button is in the panel's own header. It sits at the right of the strip that carries the Files and Changes tabs, and the glyph it wears is the place the panel would go: PanelBottom while the panel is beside the body, PanelRight while it is under the sidebar. It is not a preference and it is not in the settings panel: it is the way between two shapes of the same window.

The panel's own edge follows the shape. The border that separates it from the body is drawn only while it is beside the body; under the sidebar the divider between them is the group's own handle, and a border there would draw a second line beside it.

The column divides in a share, and the divider is draggable. The sidebar keeps the larger part of the column and the Files panel the smaller one, and the split is a share of the column's height rather than a fixed height each, so the column divides the way it was drawn whatever the window's height is.

The file keeps the shape the window was left in. ~/.muster/native-workspace.json holds it as panelLayout, and it is one of the reasons that file's shape changed: a file written before the field existed is not this build's file, and it is refused by name rather than read through. See Stored shapes below.

Two rules it depends on, each pinned by a test:

  • The button moves the panel, and the file keeps the move. the_files_panel_can_be_moved_under_the_sidebar in app/src/app_tests.rs.
  • The file keeps the shape. the_workspace_file_keeps_where_the_files_panel_sits in core/src/navigation/tests.rs.

The unfinished work in the sidebar

A file being edited and a diff holding notes become rows of the workdir they belong to — under its terminals, above New terminal. TerminalWindow::items_in is what draws them, and it is a read of live state, not a list kept in step with it: there is no stored collection of open items, and nothing to add, forget or reconcile.

  • A document is a row while it is a draft — the document on screen holding something the file does not, which is exactly the state the Save button is drawn for. Typing makes the row; saving, discarding, or undoing back to what the file holds takes it away. A file that is merely open, or merely read, is not unfinished work, so clicking through ten files adds no row of unfinished work: what the pane is on is a row of its own, and it is the next section.
  • A diff is a row while it holds a note, one row per file the notes are on, from ReviewState::noted_paths. A whole change set is not a row of its own: its notes belong to the files they were written on, and each of those is a row a person can click back into. Outdated notes do not count, by the same rule the diff's own count uses, so the panel and the pane cannot disagree.

Both carry what is waiting on them in the row's one label string — code.rs (edited), notes.txt (2 notes) — because the marker is what tells your typing apart from your review at a glance. The whole row is the item, so it has no close button: a draft is finished in the editor (Save or Cancel) and a note is deleted where it was written, because a quiet X that threw away unsaved edits or a written note would be a destructive action wearing a neutral control's clothes.

Three rules the rows depend on, each pinned by a test in app/src/sidebar_tests.rs:

  • One open thing, one selected row. The item row wears the selection, and the terminal row and the workdir row give it up: the workdir row stands in for "this is what the pane is showing" only when nothing inside it is. shown_by holds that the pane only shows an item while its own checkout is active, so a row under any other workdir is never the selected one.
  • Leaving a draft asks about the draft. request_open_item goes through request_document_transition, so jumping from a file being edited to a review raises Save / Discard / Cancel exactly as going to a terminal does.
  • A row is drawn only under a workdir the panel still shows. Notes outlive the worktree row they belong to, so an archived or removed workdir takes its rows with it by simply not being drawn, and restoring it brings back the ones its review still holds.

A row's label is one line. The room a name has is not the same all the time: the pointer on a worktree row gives it to the figures and to the close button, so a name that fitted a moment ago no longer does. It is then cut with an ellipsis, never wrapped onto a second line, which would spill out of the row and over the one below it. The same holds for the heading's name and path, for the terminal rows, and for the row that says a terminal is starting: that one is also held inside the panel rather than running under its edge.

The heading's name and path are one line of text, and one line of text has one baseline. The name is 14px and the path 11px, and a smaller text's baseline sits nearer the foot of its own box, so aligning the two boxes at the foot leaves the path hanging below the name. The path is lifted by the difference — computed from the font's own metrics, because the UI font differs per platform — so the two share the name's baseline and the path's caps start where the name's lowercase starts. the_repository_heading_shares_one_baseline in app/src/sidebar_tests.rs pins it.

The footer that adds a directory is the room under the tree, and the tree scrolls above it. The panels take the window's height less the title bar's, and not the window's height: a group sized to the window hangs a title bar's height below the window's edge, and a tree taller than its own panel then held that height against the group and took the footer with it. The button that adds a directory — the only way to add one from the panel — was simply not there. the_sidebar_keeps_its_footer_inside_the_window in app/src/sidebar_tests.rs pins it.

The order of the workdirs

The workdirs are the rows that name a repository or a plain directory, and the panel draws them in the order the workspace file keeps them in. A workdir is picked up by its heading and put down in another place, and the heading under the pointer says where it lands: the upper half of a heading is the place above that workdir and the lower half is the place below it. The line is drawn at the top of the heading that will be under the carried workdir, and the carried workdir stays where it is, dimmed, until it is put down.

The filesystem root is not a workdir. It holds every other directory rather than being one of them, and it is where an application opened from the desktop starts, so it was offered as a workdir on every launch and kept in the workspace file: it came back after it was removed, because the file still named it. It is not opened now -- core::navigation::open_directory refuses it, restore_roots leaves it out of the panel, and the next save drops it from the file. Two tests pin it: the_filesystem_root_is_not_a_workdir in core/src/navigation/tests.rs and the_filesystem_root_is_not_restored_as_a_workdir in app/src/app_tests.rs.

The home workdir is where the panel starts. It does not move, and nothing is put above it, so the workdir directly below it cannot move up either. That is the same rule Legacy drew as Home is always the first workdir, and it is the reason Navigation::home_index exists: the panel's order has a first place that is not a choice.

A move is one gesture and one state: the drag carries either a terminal or a workdir (SidebarDragKind), and the drop says where it lands (SidebarDropTarget). The workdir's menu does not move it: the panel's order is the drag's, and the menu holds what the workdir can be asked for.

Three rules the rows depend on, each pinned by a test:

  • A workdir moves to the place it is dropped on. a_workdir_row_is_dragged_to_another_place_in_the_panel in app/src/sidebar_tests.rs.
  • The workdir's menu does not move it. the_workdir_menu_does_not_move_the_workdir in app/src/sidebar_tests.rs.
  • The home workdir stays where the panel starts. the_home_workdir_stays_where_the_panel_starts in app/src/sidebar_tests.rs, and the_home_workdir_is_where_the_panel_starts in app/src/navigation.rs.
  • The order is the file's order. persistent_state writes the roots in the panel's order and restore_navigation reads them back in it, so the next launch draws the workdirs where they were left. the_panel_order_is_the_order_the_workspace_file_keeps in app/src/navigation.rs.

The view the pane is on

The centre pane shows a terminal, a file, or a change. A file is a preview and a change is a diff, and the sidebar says which one is on screen: one row under the workdir, below its terminals, that names it.

It is one row, not one per file. The pane shows one thing at a time, so clicking through ten files leaves one row that follows the file on screen. The row says the kind (Preview, Diff), wears the file's own icon, and names the file's path beside it.

It is a pin, and that is the one thing about it that is stored: a click on a terminal is a navigation and not a close, so the file that was open is still one click away and the row is the way back to it. The row's own close (×) forgets it, and closes the view too when the pane is showing it. The pin is window state, like the document itself, and is never written to disk.

One open thing, one selected row now has three claimants — the terminal rows, the workdir row and this one — and the row that names what the pane is showing wears the selection while the others give it up. A terminal left marked while a file is being read says the pane is somewhere else, and that was the one thing in the panel that was plainly wrong.

A row of unfinished work that names the same file is the pane's row already: a draft of the file being previewed, or notes on the file being diffed. Two rows for one file would say the same thing twice, so the view row is not drawn there, and the row that is drawn keeps the selection it always had.

Four rules the row depends on, each pinned by a test in app/src/sidebar_tests.rs:

  • A file on screen is a row and the terminal is not marked. a_file_on_screen_is_a_row_and_the_terminal_is_not.
  • Reading through files leaves one row. reading_through_files_leaves_one_row.
  • The row outlives the terminal that replaced it, and it is the way back. the_view_row_outlives_the_terminal_that_replaced_it.
  • The row closes what it names. the_view_row_closes_what_it_names, and a_change_set_is_a_diff_row for the other kind.

The file tree

app/src/inspector.rs owns the Files tree: a scrolling box with a window of fixed-height rows built either side of the top and the rest of the tree accounted for as space above and below, so the scrollbar belongs to the tree, not the window. It is deliberately not gpui::uniform_list: that does not publish where it is scrolled to, and the folder stack drawn over the rows it names is exactly that number. ScrollHandle::offset() is.

Three rules the tree depends on, each pinned by a test:

  • Opening a folder opens it inside the tree that is already open. A listing arriving used to clear every other expansion in the checkout, which collapsed everything above the folder being opened.
  • Collapsing a folder forgets its whole subtree — listings, pending reads and failures — and within is what keeps a sibling like src-tauri out of it.
  • Every row is flex_none. A flex column will shrink its children to fit, and the rows then stood 21 pixels apart in a tree that scrolls by 26.

The document pane

app/src/document.rs draws the file in place of the terminal, and it is an editor rather than a viewer: a typed character makes the document a draft, and a draft owns the pane's last row until it is saved or cancelled.

Three rules it depends on, each pinned by a test:

  • A save compares before replacing. core::files::write_preview compares against the text the document was opened with and rechecks target identity and bytes just before rename; a detected change is refused, but POSIX rename leaves a narrow race with a non-cooperating writer, so this is not an unconditional compare-and-swap guarantee. A pre-publication failure leaves the original in place. If directory sync fails after rename, the bytes are already visible but durability is uncertain: the pane keeps the draft/intent, updates its baseline, and offers a retry rather than navigating. A read-only file is refused because replacing it needs the directory's write access and would otherwise undo its bit.
  • A draft is edited in its source. The reading is a parse of the text as it was read, so drawing it while the editor holds another one shows a document that is no longer there. The reading is rebuilt from the saved text, which is why a save and a new parse are one step rather than two.
  • Dirty is a comparison, not an event. refresh_document_draft asks the editor's rope against the loaded text, so an undo back to what the file holds ends the draft instead of leaving a state a draft can be stuck in.

A file is read as a document and edited as its source. A Markdown file opens as its reading, the toolkit's own text view, and an SVG file opens as the drawing it is; both are one toggle away from the source they were read from, and the toggle is the header's. The reading is built from the text the file holds, so the two views are one document and not two reads of it, and a save rebuilds it from the text that was saved. A drawing has no caret, so the keyboard goes with the region that holds it rather than with the editor behind it. an_svg_file_opens_as_its_drawing_and_reads_as_source_on_request in app/src/inspector_tests.rs pins it.

The header's icon copies the file's path. The name in the header is a name inside the checkout, and the path a person pastes into a terminal or a message is the file's own path on the disk, so that is what the icon beside the name puts on the clipboard and what its tooltip names. It is drawn beside the name and not at the end of the header, and it is the one control in the header that is only an icon. the_document_header_copies_the_files_path in app/src/inspector_tests.rs pins it.

The caret is drawn, and it takes a frame to say so. The toolkit's editor paints its caret only once it has been told it is focused, and that answer comes with a frame's report of what holds the keyboard. A reading built in the frame that focuses it never hears about it — that frame's report is made before the reading's own listener is live — so the document opens with no cursor in it. The keyboard is therefore asked for at the end of the cycle, where the next frame's report can carry it: focus_visible_after_this_frame. the_open_document_draws_its_caret in app/src/inspector_tests.rs pins it.

The review composer

app/src/diff.rs draws the note being written as a row of the diff and app/src/review.rs owns the draft. It composes a range label, a Select over the line numbers a range could start on, a Textarea, and Save/Cancel actions. The picker uses the toolkit's Vec<SharedString> delegate so each candidate is only a line label. The toolkit places each annotation in a padded box; the composer takes back that padding with m_neg_3 so the card sits flush between diff lines.

Keyboard behavior is part of the composer contract: the platform modifier+Return saves by intercepting Return before the Textarea, plain Return inserts a newline, and Escape cancels. The shortcut is not currently shown in the card.

Both sides of a change are sides: a note opens on a line the diff removed as readily as on one it added, widens over removed lines, is washed over them, and is saved and exported against ReviewSide::Old. Nothing about the composer treats the added side as the real one.

Exported old-side lines are negative numbers: review_line_label writes -3 for old line 3 and 3 for new line 3, and the Markdown fence carries that side (```js{-3,-72}). The composer's picker additionally prefixes a newly added line with + and an old-side line with -; unchanged new-side context stays unprefixed. Those picker marks help distinguish diff rows and do not change the export format.

Five rules it depends on, each pinned by a test:

  • A range is written the way GitLab writes one. The + fixes where the note ends, and a + clicked on a line above an open draft says where it starts: that click widens the draft and keeps its text, because it is the same comment about more of the same change. A click at or below it is a different line and opens a new note. A note being edited is never widened this way — its range is what it was saved against.
  • Where the range ends was decided by the +. Only the picker moves it, and it is offered from the moment the note opens rather than after a range exists. Its candidates are every line the diff draws from the top of the note's own hunk down to the line it ends on, on either side: a change that removed the lines above an added one is part of the same range, so a note that ends on a + may start on a −. An untouched line is one line of both sides and is named on the side the note is anchored to. Its confirm is routed back through set_review_note_start, so the picker and the draft are one state rather than two.
  • The box does not move, and the lines do. The card is attached to the composer's anchor — the line the + or − was clicked on — so choosing another start leaves the box where the person is typing; and the range is driven onto DiffState::set_selected_lines by one helper, so the lines the note covers are washed in the diff's own selection. The wash belongs to a note, not to a draft: it is visible while the note is being written, it is kept when the note is applied — the wash is what says which lines the comment is about — and it goes back to the note that was applied last when a draft is dropped. Another note takes the mark over, and a note that is deleted, exported or delivered takes its lines with it. The toolkit's selection is a text selection, so anything that clears the window's selection takes the wash with it; the window puts it back while the diff has nothing selected, and a selection of the person's own is left alone.
  • Both sides are sides. begin_review_note opens a draft on a − line as readily as on a + one, the picker offers the removed lines above it, the wash is drawn over them, and the saved note and its export say old. A note is a range on one side, so choosing a start on the other side turns the note to that side and ends it on the last line of that side the range covers; a note that covers both sides is two notes.
  • ⌘↵ saves, and it is an interceptor rather than a key handler. Interceptors run before the input's own Return, so stopping it there is what keeps the keystroke from also inserting the newline a plain Return writes. Enter on its own still writes one, and esc still cancels.
  • A saved note is edited and removed from two icons on its header row. They are icons rather than labelled buttons so the note reads as the card's text and not as its footer, and each names itself for the tooltip and for a screen reader. The pencil opens the composer above with the saved text and range in it, because editing a note is writing another one about the same lines; the cross asks first, and the answer lives in the window (review.delete_confirmation) rather than in the card, so the dialog is the thing on screen while it stands.

Saving a comment is not saving the review. The composer's Save leaves a pending note in the diff. Only a successful Markdown export or a positively delivered OpenCode round finishes its captured snapshots: their cards, sidebar rows, count and line-range mark disappear. Notes added or edited while that operation ran, excluded outdated notes and an open composer remain. An open edit of a completed note becomes a new draft, so its next Save cannot write into a note that is gone. A failed export or send, a queue and an uncertain delivery keep the notes. Delivered snapshots remain in immutable OpenCode round history for reconciliation, but are never restored into the pending diff notes. This window remembers the exact snapshots it exported, too, so reopening the diff cannot restore them from an unresolved round. That memory is window-local, like the notes: a new window can still restore an unresolved round. Exporting does not cancel, edit or settle its queued or uncertain delivery. A completion invalidates an in-flight restore and starts a fresh read; an old callback cannot restore notes or clear the new read's loading state.

Suggest changes is a helper for writing the comment. The diff header's button fills an empty composer from the code captured by the note's own line range. Existing text is never overwritten, and the usual comment export is unchanged. This range highlight is not mouse text selection or clipboard copying; those interactions are separate work.

Terminal images

app/src/terminal/graphics.rs owns bounded Kitty graphics transfers and placements. Decode PNG/RGB/RGBA (and bounded zlib) on the PTY reader, never in a render pass. graphics_handler.rs forwards VT operations unchanged and observes real scrolling, including full history and scroll regions; history growth is not a scroll counter. Unicode placeholders use the parser's actual cells, raw foreground/underline IDs and protocol diacritics, not font glyphs or theme colours. The painter clips shared raster buffers and releases atlas entries on deletion, invisibility and view release. Classic placements are invalidated on resize so a TUI redraws rather than retaining an incorrect reflow anchor. Never modify the hash-pinned parser for graphics.

sync_updates.rs holds bounded raw synchronized updates so VT and graphics commands are applied in the same order. The status worker flushes expired updates and reader exit flushes the tail. Graphics never reads paths or shared memory named by output. Pi's explicit PI_IMAGE_PROTOCOL=kitty default is child environment only, respects configured values and does not change TERM_PROGRAM=Muster or spoof a Kitty identity.

The links in the terminal

Legacy drew the paths and the addresses a terminal printed as links: hovering one underlined it, and ctrl+click opened it. The click is what came back.

The modifier is the whole gesture. Ctrl+click, or cmd+click on macOS, and nothing else: a plain click on a path stays a click that selects nothing. Terminal output is not the person's own text, so nothing in a build log opens a browser on its own. A click a program asked for -- a terminal in mouse-reporting mode -- is the program's, and no link is offered.

A path is read the way the shell reads it. A bare name is the file where the shell is sitting, because an ls prints LICENSE with no directory on it, and the shell's own directory is the operating system's answer rather than the checkout's. An absolute spelling is what it says it is. The name is then resolved through the checkout the terminal belongs to, and only a file that checkout holds is opened: a path that leaves it names nothing here, and the pane does not move.

A page is the browser's. An address names a page rather than a file, so it is its own evidence: it is offered from the text itself and nothing is asked about the disk. http and https only, which is the pair the opener refuses everything outside of.

The rules it depends on, each pinned by a test:

  • a_control_click_on_a_page_offers_it, a_control_click_on_a_path_offers_it and a_control_click_on_a_word_offers_nothing in app/src/terminal/view.rs.
  • a_printed_name_is_read_against_the_shells_directory in core/src/files.rs.
  • a_path_the_terminal_printed_opens_the_file_in_the_preview in app/src/app_tests.rs.

The Pi integration

Pi is an agent this app can watch in its terminals. It is not OpenCode and it does not work the same way, and the difference is worth naming.

What Pi says. Pi reports through two escape sequences, and they are not the same claim. OSC 7501 is Pi's own: it names a state, and it is the only thing that can say which state. OSC 9;4 is the progress indicator every terminal understands, and it can only say that something is running. So an explicit state wins over the progress, and the progress is what is left when Pi has said nothing more precise. The payload is bounded: a status longer than 4096 characters, a field that is not one of the four, a field named twice or a malformed message is refused whole, because half a status is not a status.

The handshake. Pi asks OSC 7501;?, and the answer is the same question back. Without it Pi keeps its state to itself. The reply is bounded per session, because a program that keeps asking is not a program asking once.

What may be believed. A terminal's output is not Pi's output: another program in front of the shell can print whatever it likes. What says a report came from Pi is that the operating system names Pi as the process in front of the shell, so that is the only thing a report is believed against -- and a report is forgotten the moment the OS says something else is in front, or the moment the shell runs something else. The panel's own list of terminals cannot answer this, which is why the OS is asked.

What the row says. The row wears Pi's glyph, a spinner while a turn is running, and the words working, task complete or last task failed. A blocked turn says nothing: the panel has no shape for a question this program asked, so the row stays neutral rather than inventing a state.

Pi is not a review destination. The review menu offers Markdown (the default) and OpenCode only. Pi's sidebar status and worktree protection stay independent. The round store no longer admits the pi destination spelling. A file holding that removed shape is refused by name and is never overwritten or deleted, like any other shape this build cannot read. No compatibility reader or migration is kept.

A worktree Pi is working in. A live Pi process whose directory is inside a worktree keeps that worktree from being archived or deleted, even when the terminal it runs in belongs to another worktree. The directory fails closed: a process whose directory the operating system will not name protects the worktree it might be in. The identity does not: a process the OS will not name is not counted as Pi at all, which is what the parked app does too and is not changed here.

The OpenCode integration

OpenCode is a service a person starts, and this app is a client of it. It never starts one and never ends one: core::opencode::discover reads the registration and checks that the service answers there, and a service that is not running leaves the rows with no state, which is a state to wait in rather than a failure to report.

What is read. core::opencode::observe is the reading layer: the sessions the service lists, the agent catalog, which sessions are running, and which are waiting for a question or a permission. Three things make it safe to draw.

  • The session list is read without a directory, because a session that moved to another worktree is not listed by the directory it left, and the row that would say so would draw nothing at all. The catalog and the pending lists answer for one location, so each location is asked about on its own.
  • A pending list that cannot be read leaves its sessions unknown rather than idle: a question this app could not see is not a question that was not asked. Every location the service names is asked about, bounded by the observation's deadline rather than by a count of locations: a location this app never asked about is a different finding from one the service could not answer for, and only the second of them is worth a warning. The pending read is given half of what is left of the deadline, so the catalog that names the agents still has the other half.
  • A colour the service gave is checked for being a colour before it is painted (theme::service_tint), and the theme's own ink is what a row wears instead.

What a row says. app/src/agent_watch.rs turns that reading into what a terminal row wears: a spinner while a turn runs, a question while it waits, the error ink after a failed turn, the agent's own name and colour, and how long ago the service last touched the session.

What the terminal says about itself. The TUI reports through OSC 7501, the same escape sequence Pi uses and a different contract: app/src/terminal/opencode_status.rs reads the state it names and the session it is about. The report is written into the terminal that produced it, so it says what this terminal is doing -- the one thing the service cannot say, because the service has no way to know which of its sessions a terminal is showing. So the report is what the row draws, and the session it names is the session the row is about; the service is asked about the rest, which is how the last turn ended. A report is believed only while the operating system says OpenCode is what is in front of the shell, and it is forgotten the moment that stops being true, or the moment the shell runs something else.

A session that has no title yet. The TUI writes the program's own name while the session it shows has no title, and a session earns its title by running: the turn a person wants to watch is often the one whose session cannot be named by the title. The report is what names it, and a session the service has not heard of yet is named by nothing, so the row draws the state without the agent's name and colour. This is more than Legacy did: Legacy named a session by its title only, so the first turn of a fresh session drew no state at all.

Where the reading happens. The poll is on the background executor and comes back as a value, so a service that takes its time costs a tick of the panel and nothing else. Every answer carries the generation of the request it answered, and an answer from an earlier generation is dropped rather than drawn: a stale answer describes a world that is no longer on screen. The task belongs to the window, so it stops when the window goes.

The review round. A review to OpenCode is a round in ~/.muster/opencode-rounds.json, and the store is what makes a send safe to retry. A busy session is asked about rather than interrupted: Send now, Queue or Not now. A queued round is written down before anything is offered, with the exact message it will be sent with, so a round that waits is a round that provably was never taken. A turn that finishes settles only the rounds that were already delivered to that session: the round that was just flushed belongs to the turn that follows, and an acknowledgement is not a note being resolved. A send that may have landed is never re-offered on this app's own.

A queue is offered only on a fresh reading of the service, and only to a session that is positively free: running, waiting for an answer, and a pending list that could not be read are all reasons to wait, and a round whose session is not in the checkout any more waits rather than holding up the rounds behind it. One reading answers the whole queue, so several waiting reviews do not spend a deadline each. An ordinary send reads the activity again before it offers anything, and claims nothing until it knows: a busy session is a question for the person, and an unknown pending state is a refusal.

Following the work. app/src/follow.rs moves a terminal's row when the work it is doing moves to another worktree. The process, its scrollback and its window stay exactly where they are: nothing here writes to a terminal, feeds a directory to a shell, or tells a program in front of the shell where to go. Two questions are followed separately — the shell's own directory, and the agent's work — and the setting says which of them (off, cd, agent, both). Both is the default: a row says where the work is, and the work is the directory the shell is sitting in or the place an agent is working, so a row left under the worktree a terminal was opened in says where it was opened rather than where it is. A move needs a destination that is a registered checkout: the deepest registered root wins, a nested Git checkout this app does not know about is never folded into its parent, and two roots that are one directory are ambiguous. Exactly one terminal may be showing the session, and a move the person made by hand is not undone by an observation that is older than it.

The effective target is kept, not just the cursor: no new tool work is not a return to the session's starting directory. A session's own directory is its origin, the baseline is the moment following was enabled, and work finished before that is not a relocation. One pass runs at a time, and a result that arrives after the facts it was computed from have changed is dropped rather than applied.

Removing a worktree. The service is asked before anything is archived or deleted, because a session that was started outside this app has no terminal of this app's to look at. The read is fresh: a row that said "idle" a minute ago is not what the service says now, and the panel's own state is only a fallback. Three answers come out of it — nothing is working there, something is, or nobody could say — and the third one is a refusal, because an answer that could not be read is not an idle session. A service that is not running at all is not evidence of anything, so it only refuses when this app had already seen it working; that evidence survives a poll that could not be read, and only a positively idle session erases it. The removal is reserved before the network is waited on and revalidated before anything is archived or deleted, so a worktree that changed while the service was being asked is not removed on the strength of a stale answer. The Pi guard stays: the operating system's answer about Pi's processes is a separate claim, and neither one replaces the other.

The preferences panel

app/src/settings.rs is the panel, and the shape it draws is core::settings::AppSettings — the same schema Legacy generated its dialog from, so a preference added there is a row and a closure rather than a branch in a template.

Three things about it are decisions rather than arrangement:

  • It is a draft, and nothing reaches the file until Apply. That is what makes Cancel possible, and it is why a size somebody is still typing is never a size anything is drawn at. The draft is built whole on every open.
  • The four sizes live in their fields, not in the model. A toggle and a select report their change; a number field is a person mid-thought. The range is applied when the draft is written, and the clamped number is written back into the field, so the panel closes on what is on file rather than on what was typed.
  • A broken file opens the panel rather than stopping it. Whoever typed it is who can fix it, so the fields show the defaults with the failure beside them — and Apply refuses, because that file is the only copy of somebody's preferences.

The preferences are in four sections, and the panel is a card with a sidebar. Interface, Terminal, Editor and Review notes are the four sections, and the panel draws a sidebar of them on the left and the rows of the one on screen on the right: a card of a fixed size, where the sidebar does not change with the section and a section with more rows than fit scrolls inside the card rather than growing it. A section is a button, so it is reached with the keyboard as well as with the pointer, and the section on screen wears its own surface and ink rather than an accent bar. The section on screen is window state, like the draft, and is never written to the file.

Apply writes the file and the review location, and it does not repaint a live terminal. Nothing in this app can change a running grid's font, cursor or scrollback yet. A panel that implied otherwise would be a lie about what a preference does, so it says nothing about it instead.

The Settings action in the title bar opens this panel, and the review storage, Clear cached exports and Forget delivered reviews moved into its Review notes section: they are one surface now, which is what AGENTS.md's Settings → Forget delivered reviews… had been claiming.

The Review notes section is two blocks, and only the first is a preference. The storage row carries the folder it currently resolves to as a third line under its help text, cut to the column and named in full on a rest; the two maintenance actions are below it on one right-aligned line, because as rows of their own each button stretched to the panel's width, which is a toolbar drawn across the dialog and makes a maintenance command look like the panel's main one. A disabled action says why: both are offered from the title bar, and the one that is unavailable is unavailable for a reason the panel states on a rest.

Clearing the cache needs a checkout, not a diff open. The default location is one folder, ~/.muster/tmp/reviews, that every checkout writes to, so which diff is open is not what the action acts on. It used to be disabled in exactly the state the panel is opened in — the A08 failure the forget route had — and the confirmation says so, because clearing is now reachable from anywhere.

The tooltip

app/src/tooltip.rs is the window's tooltips, and every trigger in the app goes through its RestingTooltip rather than the toolkit's managed one. The managed tooltip switches: a trigger entered while the last tooltip is still up takes its place at once, so the wait is paid once per session rather than once per row, and browsing the Files tree paints the path of every row the pointer crosses. Here each trigger pays the wait — Tooltips::rest clears the overlay before it asks for the next one, so nothing is inherited — and the wait itself is the app's own tooltip::SHOW_DELAY, longer than the toolkit's half second because most of these triggers are ones the pointer crosses on its way somewhere else.

A trigger is a canvas laid over the control and a window listener it registers as it is painted, not a hover: Button::on_hover is inert on a disabled button, and a disabled button still has to say why it is disabled. The canvas is absolute().inset_0() and nothing else: size_full() on an absolute child of a plain element measures against a zero-height reference and lands it below the control rather than over it, which only shows up on a trigger that is not a button. The trigger is named by its bounds, which is what makes a move from one row to the next a new trigger rather than a switch. The window host dismisses what was up on a press or when the pointer leaves the window, as the managed one did. tooltip_tests pins the wait: every_trigger_waits_its_own_time on the sidebar, a_file_row_names_its_path_after_the_pointer_rests_on_it on the Files tree, and a_disabled_row_still_names_itself on a button that cannot be hovered.

The vendored parser

vendor/alacritty_terminal-0.26.0 is a security backport, not vendored noise. crates.io 0.26.0 is the latest published release and panics on a remotely triggerable input, and upstream has published no corrected release to move to. vendor/alacritty_terminal-0.26.0/PATCHES.md records the defect and the one-line fix; scripts/patched-alacritty.mjs hash-checks the whole tree, so an edit there breaks the audit gate.

The [patch.crates-io] table lives in the root Cargo.toml, which is the workspace's virtual manifest: only the workspace root's patch table is read.

Security

scripts/audit-rust.mjs reads the locked graph of core/Cargo.toml and app/Cargo.toml, refuses any path package it cannot hash-pin, and queries OSV. node --test scripts/audit-rust.test.mjs covers it. A new path dependency that is not a workspace member will fail the audit closed, which is the intent.

An advisory that cannot be fixed here is accepted, and it is named. The accepted list in scripts/audit-rust.mjs carries the advisory, the locked packages it covers, and the reason it cannot be reached in this tree. An accepted advisory is reported on every run, with its reason, and it does not block the release: it is never silently dropped, and an entry that no longer applies to the locked tree says so, so the list cannot outlive the advisory it names. The accepted list is currently empty.

Adding to the port

  • A module that no window needs belongs in core, even if it came out of the old Tauri tree's services. That tree is pub(crate)-shaped and its types move as one dependency, not as copies.
  • A module that draws, takes a key event, or owns a widget belongs in the app crate, whose source is app/src/. The VT parser and the grid are here on purpose: nothing else parses terminal output, so a crate for them would have one caller.
  • Reading a checkout's files and writing one are the same boundary, in core::files, for the reason above: a second path to a file on disk is a second path policy. Writes of existing checkout files go through write_preview. Review Markdown export is the deliberate exception: it creates a new file under .muster/reviews through export_review_markdown, which validates the registered checkout, rejects symlinks, and creates the export without overwriting. Clearing reviews only removes marked app exports from the default cache and refuses workdir mode. Workspace state in ~/.muster is separate and app/src/state_writer.rs owns it.

Versions are v1.. after the first major release

Every version this project shipped until the first major release was v0.<minor>.<patch>. The major stayed 0 until someone said otherwise in a commit that also did the migration work described below, and a v1.0.0 was not a version bump: it was the release where the schema rule below had to already be in place, so it was its own deliberate act, not a consequence of a --bump. A v1.0.0-rc.N tag was that act's candidate rather than the act: the crate carried the pre-release version the tag named, GitHub filed the release as a pre-release, and v1.0.0 stayed unclaimed until the final one shipped.

That commit is the one that published v1.0.0, and the schema rule was already in place when it did: neither stored file carries a version, and a file that carries one is refused by name rather than read through. Versions from here are v1.<minor>.<patch>.

Version changes, release preparation, tags and publishing require an explicit user request. Implementing or fixing a feature does not authorize a version bump, even when it changes a stored shape or public contract. Leave versions unchanged while the user tests the work. The rules below determine the next release's version only when the user requests that release.

Consequences:

  • A breaking change to a stored shape, an IPC contract, a preference key or a file format is made in place, and the version moves a minor. Nothing waits for a deprecation window, because nothing has to read what an older build wrote.
  • There is no "deprecated but still supported" surface, no feature flag guarding a rewrite, and no compatibility layer held "one release" for anyone. The old path goes in the same commit as the new one.

Stored shapes: one shape, no version, and no migrations

There is no database in this app. What it keeps is two JSON files in ~/.muster -- the native workspace and the review round store -- and neither carries a version number. SCHEMA_VERSION and schemaVersion are gone from both, and the file's own shape is the contract: every field required, deny_unknown_fields, so a file this build cannot read simply fails to parse. This app is going out again and has no users but one, so there is no file written by an earlier build that anybody has a reason to keep working, and a number that could disagree with the shape is a second thing to keep true.

While that is true:

  • There is no ladder, because there is no number. A stored shape is what it is: it is changed in place and the change is not a version bump. A file carrying a schemaVersion key is not this build's file and is refused like any other one.
  • A file this build cannot read is refused by name, never read through defaults and never written over. Deleting it starts an empty store.
  • Never write a migration. No ALTER TABLE, no version ladder, no back-compat shim, no compatibility reader, no #[serde(default)] on a field nothing writes. Delete the old path with the change, and leave out a field that exists only to read what an older build wrote.

This is not a rule that expires at 1.0.0. It becomes a question worth a ladder when somebody else's work is in those files, and that is a fact about who is using the app rather than about a release number.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.