feat: persist & restore per-project input mode (#14)

The input mode always started in simple; a learner who quit in advanced
had to re-toggle every launch. Store the mode per-project in project.yaml
(project.mode:, optional, default simple) and restore it on every open.

Mode is live UI state, not schema: the worker stamps the current mode
into project.yaml on every write, so a later command rewrites the live
value rather than clobbering it — no db round-trip needed. The mode is
persisted on unload (quit + project switch) so the mode you leave a
project in is always what reopens; the `mode` command also persists
immediately. A switch saves the outgoing mode, then restores the
incoming project's stored mode.

New --mode simple|advanced CLI flag (precedence --mode > stored >
simple; combines with --resume). A teacher can ship a project that
opens in advanced mode and export it to students (the mode travels in
the zip).

ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
This commit is contained in:
claude@clouddev1
2026-06-02 06:47:34 +00:00
parent ae57c6fc82
commit 4cd574b909
16 changed files with 769 additions and 14 deletions
+127
View File
@@ -561,3 +561,130 @@ note pointing to this ADR.
- `history.log` becomes the persistent history surface.
Once `replay` (OOS-2) and `undo` (OOS-1) land, they read
from the same file with no schema changes.
## Amendment 1 — Persist & restore the input mode (2026-05-31, issue #14)
### Problem
`--resume` (and any project open) always started in the default
`simple` input mode. A learner who quit in advanced mode had to
re-toggle on every launch. The mode the user was in is a real,
restorable preference; losing it each session is a small but
repeated UX cost.
### Decision
The **input mode is per-project state stored in `project.yaml`**,
restored on every open and persisted as it changes. A teacher can
prepare a project that opens ready in advanced mode and hand it to
students; a learner who works across projects has each one's mode
restored when it loads ("loading triggers the mode switch each
time" — the user's framing). This is a deliberate, useful form of
the "mode travels with the project" property, *not* an accidental
leak (the alternative — a private per-user file — was rejected for
exactly that reason: it would prevent the teacher-prep use case).
**1. Storage — `project.yaml`, alongside `created_at`.** A new
optional `mode:` key under the `project:` mapping:
```yaml
version: 1
project:
created_at: 2026-05-31T00:00:00Z
mode: advanced
```
It is **project metadata, not schema**`rebuild` ignores it
(reconstructs the db from tables/data; the mode plays no part).
The field is **optional with a `simple` default**: pre-#14 files
(no `mode:`) parse unchanged, no version bump, no migrator — the
same backward-compatible pattern as the `unique` index flag. It
**does** travel in an `export` zip (it is part of `project.yaml`),
which is the intended teacher→student behaviour.
**2. Mode is live UI state, not stored in the database.** Unlike
`created_at` (whose source of truth is gone after creation, so it
round-trips through the internal metadata table), the mode's
source of truth is always live — it is `App.mode`. So it is **not**
put in the database. Instead the persistence handle carries the
**current mode**, and the worker **stamps it into `project.yaml`
on every write**. Because every schema-mutating command rewrites
the whole file, writing the *current* mode each time means a later
command can never clobber it back to the default — there is
nothing to "preserve", only the live value to write. (This
replaced an initial over-engineered design that mirrored the mode
in the db metadata table; the simpler "write what we're in"
approach is correct because the mode is never reconstructed from
text.)
**3. Restore precedence: `--mode` > stored > `simple`.** A new
CLI flag `--mode simple|advanced` overrides the stored mode at
startup; it combines with `--resume` and a positional path (not
mutually exclusive — on collision the flag wins). At boot the
runtime reads the stored mode (`Persistence::read_stored_mode`,
which returns `None` for an absent field so "no preference" stays
distinct from an explicit `simple`), applies the flag, sets
`App.mode`, and seeds the persistence handle so the resolved mode
is what subsequent writes record. The `--mode` override applies
**only at boot** — a later project switch restores that project's
own stored mode.
**4. Mid-session changes.** The `mode` command emits a new
`Action::PersistMode`; the runtime records it through the worker
(`Database::set_mode`), which updates the live mode and writes
`project.yaml` immediately (crash-safe). Persisting the mode is
**best-effort** throughout: a failure must never escalate a UI
action into a fatal (the in-memory mode has already changed).
**5. Persist on unload (the deciding rule).** The mode is written
whenever the current project is **unloaded** — on quit and on a
project switch (load / new / save-as / import), the runtime calls
`set_mode(App.mode)` on the outgoing database before it is dropped.
This is what makes the stored mode **deterministic and
non-confusing**: by the time you leave a project, the mode you were
in is always recorded — including a bare `--mode` override or a
read-only session that ran no command. (An earlier "persist only on
the `mode` command or a schema-changing command" rule was rejected
as confusingly *selective* — whether a `--mode` override stuck
depended on whether you happened to run a DDL. "On unload" was
chosen over "on every command" to avoid rewriting `project.yaml`
and bumping its mtime, which orders the load picker — on every
read-only `select`/`show data`.) On a switch the **outgoing**
project's mode is saved first, then the **incoming** project's
stored mode is restored and carried to the `App` via the
`ProjectSwitched` event ("loading triggers the mode switch each
time").
### Scope / non-changes
- No new project file, no database schema change, no migration.
- `rebuild` is unaffected (mode is not schema; the round-trip
through text never touches it).
- The default for a brand-new project is unchanged (`simple`).
- Coverage: `mode.rs` keyword parse/round-trip +
`resolve_startup_applies_flag_then_stored_then_default`
(precedence helper);
`yaml.rs::{mode_round_trips_through_serialize_and_parse,
parse_schema_defaults_mode_to_simple_when_field_absent,
parse_stored_mode_distinguishes_absent_from_explicit,
parse_stored_mode_falls_back_to_none_on_unknown_value}`;
`persistence::{read_stored_mode_round_trips_a_written_project_yaml,
read_stored_mode_is_none_for_a_missing_project_yaml}`;
`db::{set_mode_persists_and_survives_a_later_ddl_command (the
core no-clobber guarantee),
set_mode_persists_even_with_no_prior_command (the persist-on-unload
guarantee)}`; `archive::export_carries_the_stored_input_mode`
(the teacher-export round-trip); `cli` `--mode` parse/precedence;
`app::{mode_command_changes_mode_and_emits_persist_action,
mode_command_via_one_shot_escape_persists_advanced,
project_switched_event_restores_the_stored_mode}`. The runtime's
unload call sites (quit + `handle_project_switch`) are thin
wiring over the tested `Database::set_mode`.
### Relationship to the Iteration 6 backlog
Issue #14 named this an Iteration 6 piece (persistent input
history / `--resume`). `--resume` itself already shipped; this
amendment adds the mode dimension. It is independent of the
`history.log`-based input-history hydration (§12), which remains
its own piece of Iteration 6.