Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .agent/skills/add-or-change-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
title: Add or change a configuration field
status: current
version: 0.1.0
last_updated: 2026-05-30
last_verified: 2026-05-30
source_refs:
- crates/aura-core/src/config.rs
- crates/aura-core/src/config_schema.rs
- crates/aura/src/runtime.rs
- crates/aura/src/cli/config.rs
- docs/configuration.md
owner: "@rfluid"
tags: [skill, config, cli]
---

# Add or change a configuration field

Use this skill when adding a new knob to `config.toml`, changing the type or
allowed values of an existing field, or wiring a config value into the running
app. Read [`docs/configuration.md`](../../docs/configuration.md) first — it
documents the five config layers and the precedence rules this procedure keeps
in sync. This skill is the *how-to-change-it*; that doc is the *what-it-is*.

The cardinal rule: **the typed struct is the source of truth, the field registry
mirrors it, and a test enforces they never drift.** If you add a struct field
without a registry descriptor, `registry_covers_every_field` fails the build —
that's by design, not an obstacle. Work *with* it.

## Where each layer lives

| Layer | File | You touch it when… |
|---|---|---|
| Typed struct | `crates/aura-core/src/config.rs` | always (the field itself) |
| Field registry | `crates/aura-core/src/config_schema.rs` | always (describe + get/set) |
| Runtime mirror | `crates/aura/src/runtime.rs` | the field must reach the tray loop *and* modal |
| Consumer | `crates/aura/src/app.rs`, `main.rs`, … | the field actually does something |
| CLI handler | `crates/aura/src/cli/config.rs` | almost never — it's registry-driven |
| Docs | `docs/configuration.md`, `docs/cli.md`, `README.md` | always |

The CLI (`describe` / `get` / `set` / `wizard` / `init` / `document`) and the
`#`-commented `config.toml` template are **all driven by the registry** — you do
not write per-command code for a new field. Add the descriptor and every surface
updates for free.

## Adding a new scalar field under `[display]` / `[update]`

1. **Add the struct field** in `config.rs` (on `DisplayConfig` or
`UpdateConfig`). Give it a doc comment — it's the prose your registry
`description` will quote. Update that struct's `Default` impl. Use
`#[serde(default)]` on the field (or rely on the struct-level `#[serde(default)]`)
so old configs without the key still parse. Optional fields are `Option<T>`.

2. **Add a `FieldDescriptor`** to `fields()` in `config_schema.rs`, in
template-emission order (`display.*` before `update.*`). Fill every field:
`key` (dotted), `type_label` (`string`/`string?`/`string[]`/`bool`/`u32?`),
`allowed` (`&[]` for free-form), `default`, `summary` (one line, also the
inline `#` comment), `description` (full prose), `example`.

3. **Wire `get_value` and `set_value`** (same file) — add a `match` arm for the
new key in each. Reuse the parse helpers: `parse_enum`, `parse_bool`,
`parse_opt_u32`, `parse_opt_string`, `parse_list`. Constrained values must go
through `parse_enum` against the *same* `allowed` slice you put in the
descriptor.

4. **Run the guards:**
```bash
cargo test -p aura-core config_schema
```
`registry_covers_every_field` proves the struct and registry agree;
`get_and_set_round_trip_every_descriptor` proves your `example` actually sets;
`render_commented_round_trips_*` proves the commented template still parses
back to the same config.

5. **Consume the value.** A field that nothing reads is dead config. If only the
modal needs it, read `config.display.<field>` in `app.rs`. **If both the tray
poll loop (`main.rs`) and the modal need it**, mirror it through
`runtime.rs`: add a `static AtomicBool`/etc., an accessor, and a line in
`set_from_config`. Reapply any platform state there too (see the macOS
activation-policy precedent). This is what keeps the background loop from
drifting against a freshly-reloaded config.

6. **Document it.** Add a row to the field-reference table in
`docs/configuration.md`, refresh that doc's `last_verified`, and update
`docs/cli.md` / `README.md` if the surface changed. The doc's
`registry_covers_every_field` note means the tables should always match
`config describe`.

## Changing an existing field's allowed values or type

- Update the `allowed` slice (or `type_label`) on the descriptor **and** the
matching `parse_enum`/parser call in `set_value` — they must list the same
set, or `set` will accept/reject inconsistently with what `describe` shows.
- If you rename or remove a key, keep deserialization lenient: unrecognised
values should fall back to a sane default rather than failing the parse (see
how `anchor` treats the legacy `"auto"`). Never make an old on-disk config
fail to load.
- Re-run the `config_schema` tests; fix the `example` if it no longer validates.

## Adding a field to a repeatable table (`[[agents]]` / `[[plugins]]`)

These are **not** `get`/`set` targets — they're managed via `aura agents` /
`aura plugin` / `config edit`.

1. Add the field to `AgentConfig` / `PluginConfig` in `config.rs` (`#[serde(default)]`
for backward compatibility).
2. Add a `SectionField` to `agent_fields()` / `plugin_fields()` in
`config_schema.rs` so `describe` and the template header document it.
3. Update the round-trip assertions in `render_commented_round_trips_populated`
to cover the new field.
4. Document it in the relevant table in `docs/configuration.md`.

## Smoke test

```bash
cargo run -p aura -- config describe # new field listed?
cargo run -p aura -- config describe <key> # full prose + current value
cargo run -p aura -- config set <key> <value> # validation + near-miss keys
cargo run -p aura -- config get <key>
cargo run -p aura -- config init --force # regenerate; confirm the # comment
cargo run -p aura -- config validate
```

For a runtime-mirrored field, also confirm the reload paths pick it up without a
restart: edit the value, click the tray icon (re-open), and check the behaviour
changed (config is reloaded on every open and on the modal Refresh button — see
the reload-triggers section of the configuration doc).

## Checklist

- [ ] Struct field + doc comment + `Default` updated (`config.rs`)
- [ ] `FieldDescriptor` / `SectionField` added (`config_schema.rs`)
- [ ] `get_value` + `set_value` arms wired (scalars only)
- [ ] `cargo test -p aura-core config_schema` green
- [ ] Value actually consumed (and mirrored via `runtime.rs` if dual-surface)
- [ ] `docs/configuration.md` table + `last_verified` updated; `cli.md` / `README.md` if needed
- [ ] Smoke-tested via the `config` CLI
41 changes: 30 additions & 11 deletions crates/aura-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,24 +131,33 @@ pub struct DisplayConfig {
/// edges. Default false: Aura behaves like a tray popup with a fixed
/// width that auto-fits its content vertically.
///
/// Turning this on toggles three things together because they only make
/// sense as a set:
/// 1. `titlebar` is created instead of `None`, so the OS draws the
/// standard chrome (and, on KDE / GNOME, the window menu items like
/// "all desktops" / "always on top" come along for free — they're
/// compositor-rendered, not drawn by Aura).
/// 2. `is_resizable` is set true and Wayland decorations are server-
/// side so the compositor exposes resize edges.
/// 3. The content-fit auto-resize that runs on every layout pass is
/// suppressed, otherwise it fights every drag.
/// Turning this on draws the OS title bar and its window-menu items (on
/// KDE / GNOME the "all desktops" / "always on top" entries come along for
/// free — they're compositor-rendered, not drawn by Aura).
///
/// Chrome only controls the *title bar*. Whether the modal auto-resizes to
/// fit its content is a separate axis, see [`Self::auto_resize`].
pub window_chrome: bool,
/// Whether the modal auto-resizes to fit its content height.
///
/// On every layout pass a content-fit callback measures the rendered
/// content and grows / shrinks the window to match (capped at the screen
/// work area and [`Self::max_height`]). `None` (default) means "yes" — the
/// modal auto-fits. Set `false` for a fixed-size window.
///
/// This is *not* about user drag-to-resize (the window manager owns that);
/// it only toggles Aura's own content-fit. Independent of
/// [`Self::window_chrome`]: the auto-fit behaves the same with or without
/// the native title bar.
#[serde(default)]
pub auto_resize: Option<bool>,
/// Optional upper bound (in logical pixels) on the modal's auto-fit
/// height. The content-fit callback already caps growth at the screen's
/// available work area; this lets the user impose a tighter ceiling so
/// the modal never grows past, say, 500 px even on a tall display.
///
/// `None` (default) means "only the work-area cap applies".
/// Ignored when [`Self::window_chrome`] is true (auto-fit is off then).
/// Ignored when [`Self::auto_resize`] is false (no auto-fit to cap).
#[serde(default)]
pub max_height: Option<u32>,
/// Swap the modal's user-facing copy for an aggressive / unhinged variant
Expand Down Expand Up @@ -179,6 +188,15 @@ fn default_anchor() -> String {
}
}

impl DisplayConfig {
/// Effective auto-fit behaviour: `auto_resize` unset (`None`) means the
/// modal auto-resizes to fit its content height. `false` makes it a
/// fixed-size window. Applies whether or not [`Self::window_chrome`] is on.
pub fn auto_resize(&self) -> bool {
self.auto_resize.unwrap_or(true)
}
}

impl Default for DisplayConfig {
fn default() -> Self {
Self {
Expand All @@ -188,6 +206,7 @@ impl Default for DisplayConfig {
show_in_app_switcher: false,
dismiss_on_focus_loss: true,
window_chrome: false,
auto_resize: None,
max_height: None,
goblin_mode: false,
}
Expand Down
39 changes: 34 additions & 5 deletions crates/aura-core/src/config_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,27 @@ pub fn fields() -> &'static [FieldDescriptor] {
type_label: "bool",
allowed: &["true", "false"],
default: "false",
summary: "Show native window chrome (title bar + drag-to-resize).",
summary: "Show the native window title bar.",
description: "Show the OS-native window chrome (title bar + minimize / maximize / \
close buttons) and let the user resize the modal by dragging its edges. \
Default false: Aura behaves like a tray popup with a fixed width that \
auto-fits its content vertically. Turning this on also enables resizing and \
suppresses the content-fit auto-resize (which would otherwise fight the drag).",
close buttons). Default false: Aura behaves like a tray popup with no title \
bar. This only controls the title bar — whether the modal auto-resizes to fit \
its content is the separate display.auto_resize knob.",
example: "false",
},
FieldDescriptor {
key: "display.auto_resize",
type_label: "bool?",
allowed: &["true", "false"],
default: "unset (auto-fit)",
summary: "Auto-resize the modal to fit its content height.",
description: "Whether the modal auto-resizes to fit its content height. On every \
layout pass a content-fit callback grows / shrinks the window to match (capped \
at the screen work area and display.max_height). Unset (default) means auto-fit \
is on. Set false for a fixed-size window. This is not user drag-to-resize (the \
window manager owns that) — only Aura's content-fit. Independent of \
window_chrome, so the auto-fit works the same with or without the title bar.",
example: "true",
},
FieldDescriptor {
key: "display.max_height",
type_label: "u32?",
Expand Down Expand Up @@ -328,6 +341,11 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result<String, SchemaError> {
"display.show_in_app_switcher" => cfg.display.show_in_app_switcher.to_string(),
"display.dismiss_on_focus_loss" => cfg.display.dismiss_on_focus_loss.to_string(),
"display.window_chrome" => cfg.display.window_chrome.to_string(),
"display.auto_resize" => cfg
.display
.auto_resize
.map(|b| b.to_string())
.unwrap_or_else(|| "(unset)".to_string()),
"display.max_height" => cfg
.display
.max_height
Expand Down Expand Up @@ -360,6 +378,7 @@ pub fn set_value(cfg: &mut AppConfig, key: &str, raw: &str) -> Result<(), Schema
cfg.display.dismiss_on_focus_loss = parse_bool(key, raw)?
}
"display.window_chrome" => cfg.display.window_chrome = parse_bool(key, raw)?,
"display.auto_resize" => cfg.display.auto_resize = parse_opt_bool(key, raw)?,
"display.max_height" => cfg.display.max_height = parse_opt_u32(key, raw)?,
"display.goblin_mode" => cfg.display.goblin_mode = parse_bool(key, raw)?,
"update.dismissed_version" => cfg.update.dismissed_version = parse_opt_string(raw),
Expand Down Expand Up @@ -403,6 +422,13 @@ fn is_clear(raw: &str) -> bool {
)
}

fn parse_opt_bool(key: &str, raw: &str) -> Result<Option<bool>, SchemaError> {
if is_clear(raw) {
return Ok(None);
}
parse_bool(key, raw).map(Some)
}

fn parse_opt_u32(key: &str, raw: &str) -> Result<Option<u32>, SchemaError> {
if is_clear(raw) {
return Ok(None);
Expand Down Expand Up @@ -508,6 +534,7 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option<String> {
"display.show_in_app_switcher" => cfg.display.show_in_app_switcher.to_string(),
"display.dismiss_on_focus_loss" => cfg.display.dismiss_on_focus_loss.to_string(),
"display.window_chrome" => cfg.display.window_chrome.to_string(),
"display.auto_resize" => return cfg.display.auto_resize.map(|b| b.to_string()),
"display.max_height" => return cfg.display.max_height.map(|n| n.to_string()),
"display.goblin_mode" => cfg.display.goblin_mode.to_string(),
"update.dismissed_version" => return cfg.update.dismissed_version.as_deref().map(quote),
Expand Down Expand Up @@ -580,6 +607,7 @@ mod tests {
/// (which serde omits from TOML) still appear when checking coverage.
fn fully_populated() -> AppConfig {
let mut cfg = AppConfig::default_config();
cfg.display.auto_resize = Some(false);
cfg.display.max_height = Some(500);
cfg.update.dismissed_version = Some("0.0.0".to_string());
cfg
Expand Down Expand Up @@ -719,6 +747,7 @@ mod tests {
show_in_app_switcher: true,
dismiss_on_focus_loss: false,
window_chrome: true,
auto_resize: Some(false),
max_height: Some(500),
goblin_mode: true,
},
Expand Down
9 changes: 5 additions & 4 deletions crates/aura/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,10 +655,11 @@ impl Render for AuraView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let last_height = self.last_window_height.clone();
let body_scroll = self.body_scroll.clone();
// When window_chrome is on, the OS draws a title bar and the user can
// drag the edges — so suppress the content-fit auto-resize that would
// otherwise snap the height back every layout pass.
let auto_fit = !self.config.display.window_chrome;
// `display.auto_resize` governs the content-fit auto-resize that grows /
// shrinks the window to fit its content on every layout pass. It is
// independent of `window_chrome`, so the auto-fit works the same with
// or without native chrome. Default on (see `DisplayConfig::auto_resize`).
let auto_fit = self.config.display.auto_resize();
let user_max_height = self.config.display.max_height;
// How the modal re-anchors after the auto-fit resize (see
// `placement::Anchor`). Only `Bottom` triggers an active move.
Expand Down
29 changes: 17 additions & 12 deletions crates/aura/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,14 +437,13 @@ fn toggle_window(
} else {
WindowKind::PopUp
};
// `display.window_chrome` swaps the modal between two modes:
// false (default): chromeless tray-popup, fixed width, height auto-fits
// content (see app.rs::on_children_prepainted). is_resizable is
// forced false because there is no visible edge to grab anyway.
// true: native OS chrome (title bar + min/max/close), user-resizable.
// window_decorations: Server asks Wayland compositors to draw SSD;
// the auto-fit callback in app.rs is suppressed so user-dragged
// sizes stick.
// `display.window_chrome` controls only the native title bar:
// false (default): chromeless tray-popup, fixed width.
// true: native OS chrome (title bar + min/max/close). window_decorations:
// Server asks Wayland compositors to draw SSD.
// Whether the modal auto-fits its content height is a separate axis,
// governed by `display.auto_resize` in app.rs (see on_children_prepainted) —
// independent of chrome, so the auto-fit works in both modes.
let (titlebar, is_resizable, window_decorations) = if config.display.window_chrome {
(
Some(TitlebarOptions::default()),
Expand Down Expand Up @@ -475,8 +474,14 @@ fn toggle_window(
..Default::default()
};

// Cloak (Windows) hides the first-frame flash that only happens when the
// auto-fit step shrinks the window from its open-time MODAL_H to the
// content height. So it must track `auto_resize` (the same flag that gates
// the auto-fit callback / uncloak in app.rs) — NOT `window_chrome`. Tying
// it to chrome would leave a chromeless + fixed-size window (auto_resize =
// false) cloaked forever, since no uncloak step ever runs.
#[cfg(target_os = "windows")]
let cloak = !config.display.window_chrome;
let cloak = config.display.auto_resize();

match cx.open_window(opts, |_window, cx| {
cx.new(|cx| AuraView::new(config, config_path, state, cx))
Expand All @@ -502,9 +507,9 @@ fn toggle_window(
// on the second frame after the resize, showing the window at
// the correct size.
//
// Skipped when `window_chrome` is on: there is no auto-shrink
// step in that mode, so cloaking would leave the window
// invisible forever.
// Skipped when `auto_resize` is off (`cloak` is false): there
// is no auto-shrink step then, so cloaking would leave the
// window invisible forever.
let _ = handle.update(cx, |_, window, _| {
if cloak {
win32_set_cloak(window, true);
Expand Down
Loading
Loading