mnml-rs 0.2.14

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
# Programmatic layout config (task #878)

Status: design draft — 2026-08-19. Implementation to follow after
review, alongside step-3 of the auto-update work.

## What we want

Right now mnml boots to either the saved session (`.mnml/session.json`)
or a bare welcome screen. Users who want a specific starting layout
— editor + terminal + notes split — have to build it by hand every
time, or rely on the session file which drifts as they work.

Add a **declarative startup layout** block in `config.toml` so users
can say "when I open mnml on this workspace, arrange the panes like
this." The session file still wins (a saved layout represents "where
I left off"); the declarative block is the *cold-start* baseline.

## Config shape (MVP)

```toml
[startup.layout]
# List of files/pty commands to open, in the order they should be
# added. First entry lands in the initial leaf; each subsequent
# entry opens in a split of the previously-added pane.
opens = [
  { kind = "editor", path = "src/main.rs" },
  { kind = "editor", path = "src/lib.rs",   split = "right" },
  { kind = "pty",    cmd  = "cargo watch",  split = "down"  },
]
```

Each `opens[]` entry is either an editor pane with a path or a Pty
pane with a shell command. `split` is one of `"right"` (horizontal
side-by-side) or `"down"` (vertical stacked). Absent on the first
entry (nothing to split against); required on subsequent entries.

The MVP is a **linear chain of splits** — each new pane splits the
most recently added leaf. That covers 80% of "declarative startup
layout" without needing a full recursive tree grammar. Complex
arrangements (split A, focus back to root, split A a second time
against a different sibling) stay session-restore territory.

## Fallbacks

- **Missing file** → open as a scratch buffer named after the path
  (no error toast — matches how `open_path` handles new files today).
- **Missing command** → drop the entry (no Pty), toast once.
- **Empty `opens` list** → fall through to the welcome screen.
- **Session file present** → session wins. The declarative block is
  the "no session yet" default, not an override. Users who want the
  declarative layout on every boot can `.mnml/session.json` to
  gitignore + skip session restore.

## When it runs

- After the workspace directory is resolved.
- After the session-restore attempt (which may set up its own
  layout).
- BEFORE the first paint — so users see the intended layout, not the
  welcome-then-flash sequence.

Skipped when:
- `--headless` (tests set their own state).
- `--demo` (demo mode owns the layout, per `src/app/demo.rs`).
- Session file was present and restored successfully.

## Non-goals (MVP)

- **Dock widgets** — bottom/right panels + corner overlays stay
  session-driven. Docked panes have their own design doc
  (`dockable-panes.md`); folding them into `[startup.layout]`
  is a follow-up once dockable-panes phase 1 lands (#906).
- **Recursive tree** — MVP is a linear chain. If a user needs a
  T-shaped or grid layout, they build it once + let session restore
  reproduce it.
- **Per-workspace override**`[startup.layout]` lives in the
  user's global config for now. A workspace-scoped `.mnml/config.toml`
  could carry a per-workspace override later; not required for MVP.
- **Interactive designer** — no drag-drop UI for building the config
  block. Users write TOML.

## Implementation sketch

Small typed struct on `Config`:

```rust
pub struct StartupLayoutEntry {
    pub kind: String,           // "editor" | "pty"
    pub path: Option<String>,   // required for kind = editor
    pub cmd:  Option<String>,   // required for kind = pty
    pub split: Option<String>,  // "right" | "down"; None for first entry
}

impl Config {
    pub startup_layout: Vec<StartupLayoutEntry>,  // empty = disabled
}
```

Applied in a new `App::apply_startup_layout()` method called after
`App::new` but before the first `ui::draw`:

```rust
fn apply_startup_layout(&mut self) {
    for (i, entry) in self.config.startup_layout.clone().iter().enumerate() {
        if i > 0 {
            let dir = match entry.split.as_deref() {
                Some("right") => SplitDir::Horizontal,
                Some("down") => SplitDir::Vertical,
                _ => { self.toast("startup.layout: entry {i} missing `split`"); continue; }
            };
            self.split_active(dir);
        }
        match entry.kind.as_str() {
            "editor" => if let Some(p) = &entry.path { self.open_path(p); }
            "pty"    => if let Some(cmd) = &entry.cmd { self.open_pty_with_cmd(cmd); }
            other    => { self.toast("startup.layout: unknown kind {other}"); continue; }
        }
    }
}
```

Gate on:

- `self.session_was_restored` (existing signal from session load) —
  skip if true.
- `--headless` / `--demo` — skip in main.rs before calling
  `apply_startup_layout`.

## Order of operations for shipping

1. Add `StartupLayoutEntry` + `Config::startup_layout` + raw shape
   + tests for the resolver (invalid entries, empty list, all-editor,
   mixed with pty). Merge.
2. Add `App::apply_startup_layout` + gate points. Tests headless-mode
   exercise it with a synthesized config. Merge.
3. Manual UX check across:
   - Fresh workspace with no session → declarative layout appears.
   - Workspace with saved session → session wins.
   - Missing file → falls back to scratch buffer named after path.
   - Missing pty command → toasts + continues.

Split into two merges so the schema piece is reversible on its own
if UX surfaces something the sketch missed.

## Open questions

- **Ratio control.** MVP always uses `ratio = 50`. Should the config
  allow `ratio = 30` on a per-entry basis? Adds a knob; users could
  tune "editor 60% / terminal 40%" out of the box. Small addition,
  likely worth including in v1.
- **`view.reset_to_startup_layout` palette command.** Would let
  users blow away the current session state and re-apply the
  declarative block. Handy escape hatch. Add as v1.5 if users ask.
- **Env-var expansion in paths / commands.** `path = "$HOME/notes.md"`
  should probably work (matches how `[[tasks]]` treats
  `cmd = "$MY_TOOL"`). Include for v1.