feat(protocol): extract improvise-protocol crate

The crate the browser epic hangs off: Command (the wire format),
ViewState, RenderCache/CellDisplay/Snapshot, and reduce_view. It depends
on improvise-core and serde and nothing else -- no IO, no command layer,
no terminal -- and improvise-command now depends on it.

ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig,
MinibufColor, HELP_PAGE_COUNT and the key types move here. Everything
that moved is re-exported from ui::app and command::key, so no existing
path changed. The three tickets before this one are what made the move
mechanical: h0b evicted the unportable fields, a2x added the derives,
8uz took the cursor out of the shared View.

Command's client->server half is not invented. It is exactly the set of
EffectKind::ViewOnly effects whose target is the portable slice -- h0b
had already sorted the portable state from the host-local, so the
enumeration was done. Effects touching the wizard, staged drill edits,
storage or the layout cache are absent by construction. The other half
carries projections: already-formatted display values and their labels.

One thing had to be added rather than moved. reduce_view runs on a client
that holds no Workbook, and ViewState::cursors is keyed by view name, so
ViewState needs its own active_view. That makes two copies of "which view
is active"; App::switch_to_view and App::install_workbook are the only
writers, and the_two_active_view_names_never_diverge guards it. The 8uz
characterization test caught the desync the moment it appeared, because
it was calling workbook.switch_view directly.

Whether the two are *allowed* to diverge -- two clients on different
views -- is improvise-g0f, deliberately left open. It changes the shape
of improvise-cqq, and nothing here forecloses either answer.

reduce_view has 12 tests including a determinism check: folding the same
stream twice gives the same state, which is what stops the server's
mirror drifting from the client it mirrors. Every Command round-trips
through JSON. The crate is in the CI wasm job.

Closes improvise-cqi

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
This commit is contained in:
Edward Langley
2026-09-01 02:03:45 -07:00
parent 07a29aa33b
commit d25f3187d3
14 changed files with 1098 additions and 412 deletions
+2
View File
@@ -37,6 +37,8 @@ jobs:
run: cargo check -p improvise-core --target wasm32-unknown-unknown run: cargo check -p improvise-core --target wasm32-unknown-unknown
- name: improvise-formula - name: improvise-formula
run: cargo check -p improvise-formula --target wasm32-unknown-unknown run: cargo check -p improvise-formula --target wasm32-unknown-unknown
- name: improvise-protocol
run: cargo check -p improvise-protocol --target wasm32-unknown-unknown
- name: improvise-io - name: improvise-io
run: cargo check -p improvise-io --target wasm32-unknown-unknown run: cargo check -p improvise-io --target wasm32-unknown-unknown
- name: improvise-command - name: improvise-command
Generated
+10
View File
@@ -795,6 +795,7 @@ dependencies = [
"improvise-core", "improvise-core",
"improvise-formula", "improvise-formula",
"improvise-io", "improvise-io",
"improvise-protocol",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -849,6 +850,15 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "improvise-protocol"
version = "0.1.0-rc2"
dependencies = [
"improvise-core",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace] [workspace]
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-io", "crates/improvise-command", "crates/gp-tree"] members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/gp-tree"]
[package] [package]
name = "improvise" name = "improvise"
+16 -4
View File
@@ -6,9 +6,10 @@ Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO. - `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
- `improvise-formula` — formula parser, AST, `parse_formula`. - `improvise-formula` — formula parser, AST, `parse_formula`.
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands. - `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). No ratatui/crossterm. - `improvise-protocol` — the wire vocabulary and the portable session slice: `Command`, `ViewState`, `RenderCache`, `reduce_view`, key types. Depends only on `improvise-core` + serde. No IO, no command layer, no terminal.
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). Depends on `improvise-protocol`. No ratatui/crossterm.
The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`. The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`. `ui::app` re-exports the protocol's `AppMode`, `ViewState`, `ViewCursor`, `ViewFrame`, `Panel`, `MinibufferConfig`, `MinibufColor` and `HELP_PAGE_COUNT`, and `command::key` re-exports the protocol's key types, so every pre-existing path still resolves.
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where". Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
@@ -175,7 +176,9 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach. 28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach.
29. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand. 29. **"Which view is active" is stored twice, and the copies must move together.** `Workbook::active_view` is the document's; `ViewState::active_view` is the client's, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi). Only two writers are allowed to touch either — `App::switch_to_view` and `App::install_workbook` — and `the_two_active_view_names_never_diverge` guards it. Calling `workbook.switch_view()` directly desyncs them and the cursor map is then read under the wrong key; that is exactly how the 8uz characterization test broke during the extraction. Whether the two are *allowed* to diverge — two clients on different views — is improvise-g0f, deliberately unanswered here.
30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand.
--- ---
@@ -215,6 +218,15 @@ import/analyzer.rs 292 / 9t Field kind detection (Category/Measure/Time/
import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge
``` ```
### `improvise-protocol` (`crates/improvise-protocol/src/`)
```
lib.rs 472 / 1t ViewState, ViewCursor, ViewFrame, AppMode (16), Panel,
MinibufferConfig, MinibufColor, HELP_PAGE_COUNT
reduce.rs 377 / 12t Command (the wire format), HelpPage, reduce_view
render.rs 90 / 0t CellDisplay, RenderCache, Snapshot
key.rs 101 / 3t KeyCode, KeyModifiers, KeyEvent (moved from command/)
```
### Command layer (`crates/improvise-command/src/command/`) ### Command layer (`crates/improvise-command/src/command/`)
``` ```
cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
@@ -237,7 +249,7 @@ parse.rs 236 / 19t Script/command-line parser (prefix syntax)
### Semantic UI state (`crates/improvise-command/src/ui/`) ### Semantic UI state (`crates/improvise-command/src/ui/`)
``` ```
ui/effect.rs 2568 / 61t Effect trait, 50+ effect types ui/effect.rs 2568 / 61t Effect trait, 50+ effect types
ui/app.rs 2455 / 70t App, ModelState, ViewState, AppMode (16) + dispatch methods, Panel, handle_key, autosave, HELP_PAGE_COUNT ui/app.rs 2134 / 71t App, ModelState, ViewState, AppMode (16) + dispatch methods, Panel, handle_key, autosave, HELP_PAGE_COUNT
ui/cat_tree.rs 156 / 6t Category tree flattener for panel ui/cat_tree.rs 156 / 6t Category tree flattener for panel
ui/viewport.rs 128 compute_col_widths / row_header_width / visible_cols (pure geometry) ui/viewport.rs 128 compute_col_widths / row_header_width / visible_cols (pure geometry)
``` ```
+1
View File
@@ -8,6 +8,7 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies] [dependencies]
improvise-core = { path = "../improvise-core" } improvise-core = { path = "../improvise-core" }
improvise-protocol = { path = "../improvise-protocol" }
improvise-formula = { path = "../improvise-formula" } improvise-formula = { path = "../improvise-formula" }
improvise-io = { path = "../improvise-io" } improvise-io = { path = "../improvise-io" }
anyhow = "1" anyhow = "1"
+6 -1
View File
@@ -6,7 +6,12 @@
//! and applies them directly. //! and applies them directly.
pub mod cmd; pub mod cmd;
pub mod key; /// Key types moved to `improvise-protocol` (improvise-cqi) — they ride in
/// `ViewState::transient_prefix`, so they must live where the wire types do.
/// Re-exported here so `crate::command::key::…` paths keep working.
pub mod key {
pub use improvise_protocol::key::*;
}
pub mod keymap; pub mod keymap;
pub mod parse; pub mod parse;
+77 -398
View File
@@ -1,7 +1,12 @@
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
// The portable half of the session lives in `improvise-protocol`
// (improvise-cqi). Re-exported so `crate::ui::app::AppMode` and friends
// keep resolving for every existing caller.
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; pub use improvise_protocol::{
use std::collections::HashMap; AppMode, HELP_PAGE_COUNT, MinibufColor, MinibufferConfig, Panel, ViewCursor, ViewFrame,
ViewState,
};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -15,39 +20,12 @@ use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet}; use crate::command::keymap::{Keymap, KeymapSet};
use crate::format::parse_number_format; use crate::format::parse_number_format;
use crate::import::wizard::ImportWizard; use crate::import::wizard::ImportWizard;
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::CellKey;
use crate::persistence; use crate::persistence;
use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_visible_cols}; use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_visible_cols};
use crate::view::GridLayout; use crate::view::GridLayout;
use crate::workbook::Workbook; use crate::workbook::Workbook;
/// Number of pages in the Help screen. Lives here (with the `help_page`
/// state it bounds) so help-page effects stay terminal-independent; the
/// help renderer in the root crate imports it.
pub const HELP_PAGE_COUNT: usize = 5;
/// Where one client is looking within one view: the selected cell and the
/// scroll offsets that keep it on screen.
///
/// This is per-client state (improvise-8uz). It used to live on `View`,
/// inside the shared workbook, which meant one client scrolling moved
/// everyone's viewport. `View` no longer carries it at all — nothing
/// persisted it, so there was no saved-position behaviour to preserve.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewCursor {
/// Selected cell, as (row index, column index).
pub selected: (usize, usize),
pub row_offset: usize,
pub col_offset: usize,
}
/// A saved view+mode pair for the navigation stack.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewFrame {
pub view_name: String,
pub mode: AppMode,
}
/// Drill-down state: frozen record snapshot + pending edits that have not /// Drill-down state: frozen record snapshot + pending edits that have not
/// yet been applied to the model. /// yet been applied to the model.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -60,287 +38,6 @@ pub struct DrillState {
pub pending_edits: std::collections::HashMap<(usize, crate::view::RecordCol), String>, pub pending_edits: std::collections::HashMap<(usize, crate::view::RecordCol), String>,
} }
/// Accent color for a minibuffer prompt. Semantic palette only — the
/// rendering layer maps each variant to a concrete terminal color, so
/// this type carries no rendering-framework dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MinibufColor {
Green,
Cyan,
Yellow,
}
/// Display configuration for the bottom-bar minibuffer.
/// Carried structurally by text-entry `AppMode` variants.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MinibufferConfig {
pub buffer_key: String,
pub prompt: String,
pub color: MinibufColor,
}
/// One of the three side panels. Paired with [`AppMode::panel`], which names
/// the panel a mode is focused in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Panel {
Formula,
Category,
View,
}
impl Panel {
/// Every panel, in the order their toggle keys are bound.
pub const ALL: [Panel; 3] = [Panel::Formula, Panel::Category, Panel::View];
/// The name commands use for this panel — the spelling `parse_panel`
/// accepts, so `toggle-panel-and-focus` and `move-panel-cursor` args can
/// be generated rather than spelled out per panel.
pub fn name(self) -> &'static str {
match self {
Panel::Formula => "formula",
Panel::Category => "category",
Panel::View => "view",
}
}
/// The key that toggles focus to this panel, bound in Normal mode and in
/// every panel's own keymap.
pub fn key(self) -> char {
match self {
Panel::Formula => 'F',
Panel::Category => 'C',
Panel::View => 'V',
}
}
/// The mode that focuses this panel. Inverse of [`AppMode::panel`] for the
/// three top-level panel modes (the sub-modes — `FormulaEdit`,
/// `CategoryAdd`, `ItemAdd` — map back to their panel but not forwards).
pub fn mode(self) -> AppMode {
match self {
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AppMode {
Normal,
Editing {
minibuf: MinibufferConfig,
},
FormulaEdit {
minibuf: MinibufferConfig,
},
FormulaPanel,
CategoryPanel,
/// Quick-add a new category: Enter adds and stays open, Esc closes.
CategoryAdd {
minibuf: MinibufferConfig,
},
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
ItemAdd {
category: String,
minibuf: MinibufferConfig,
},
ViewPanel,
TileSelect,
ImportWizard,
ExportPrompt {
minibuf: MinibufferConfig,
},
/// Vim-style `:` command line
CommandMode {
minibuf: MinibufferConfig,
},
Help,
Quit,
/// Records-mode normal: inherits from Normal with records-specific bindings.
RecordsNormal,
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
RecordsEditing {
minibuf: MinibufferConfig,
},
}
impl AppMode {
/// Short uppercase name shown in the title bar.
pub fn label(&self) -> &'static str {
match self {
Self::Normal => "NORMAL",
Self::Editing { .. } => "INSERT",
Self::FormulaEdit { .. } => "FORMULA",
Self::FormulaPanel => "FORMULAS",
Self::CategoryPanel => "CATEGORIES",
Self::CategoryAdd { .. } => "NEW CATEGORY",
Self::ItemAdd { .. } => "ADD ITEMS",
Self::ViewPanel => "VIEWS",
Self::TileSelect => "TILES",
Self::ImportWizard => "IMPORT",
Self::ExportPrompt { .. } => "EXPORT",
Self::CommandMode { .. } => "COMMAND",
Self::Help => "HELP",
Self::Quit => "QUIT",
Self::RecordsNormal => "RECORDS",
Self::RecordsEditing { .. } => "RECORDS INSERT",
}
}
/// Context-sensitive keybinding hint for the status bar.
///
/// `ExportPrompt` and `Quit` deliberately show nothing: `Quit` is never
/// rendered, and `ExportPrompt` has no hint text yet (improvise-1c9).
pub fn hint_text(&self) -> &'static str {
match self {
Self::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
Self::Editing { .. } | Self::RecordsEditing { .. } => {
"Enter:commit Tab:commit+right Esc:cancel"
}
Self::RecordsNormal => "hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd",
Self::FormulaPanel => "n:new d:delete jk:nav Esc:back",
Self::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
Self::CategoryPanel => {
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
}
Self::CategoryAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type a category name"
}
Self::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
Self::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
Self::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
Self::CommandMode { .. } => {
":q quit :w save :import :add-cat :formula :show-item :help"
}
Self::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
Self::Help => "h/l:pages q/Esc:close",
Self::ExportPrompt { .. } | Self::Quit => "",
}
}
/// The side panel this mode is focused in, if any. A panel's sub-modes
/// count as that panel, so the panel keeps its active styling while the
/// user types into its minibuffer.
pub fn panel(&self) -> Option<Panel> {
match self {
Self::FormulaPanel | Self::FormulaEdit { .. } => Some(Panel::Formula),
Self::CategoryPanel | Self::CategoryAdd { .. } | Self::ItemAdd { .. } => {
Some(Panel::Category)
}
Self::ViewPanel => Some(Panel::View),
Self::Normal
| Self::Editing { .. }
| Self::TileSelect
| Self::ImportWizard
| Self::ExportPrompt { .. }
| Self::CommandMode { .. }
| Self::Help
| Self::Quit
| Self::RecordsNormal
| Self::RecordsEditing { .. } => None,
}
}
/// Extract the minibuffer config from text-entry modes, if present.
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
match self {
Self::Editing { minibuf, .. }
| Self::RecordsEditing { minibuf, .. }
| Self::FormulaEdit { minibuf, .. }
| Self::CommandMode { minibuf, .. }
| Self::CategoryAdd { minibuf, .. }
| Self::ItemAdd { minibuf, .. }
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
_ => None,
}
}
/// True for any cell-editing mode (normal or records).
pub fn is_editing(&self) -> bool {
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
}
pub fn editing() -> Self {
Self::Editing {
minibuf: MinibufferConfig {
buffer_key: "edit".into(),
prompt: "edit: ".into(),
color: MinibufColor::Green,
},
}
}
pub fn records_editing() -> Self {
Self::RecordsEditing {
minibuf: MinibufferConfig {
buffer_key: "edit".into(),
prompt: "edit: ".into(),
color: MinibufColor::Green,
},
}
}
/// True when this mode is a records-mode variant.
pub fn is_records(&self) -> bool {
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
}
pub fn formula_edit() -> Self {
Self::FormulaEdit {
minibuf: MinibufferConfig {
buffer_key: "formula".into(),
prompt: "formula: ".into(),
color: MinibufColor::Cyan,
},
}
}
pub fn command_mode() -> Self {
Self::CommandMode {
minibuf: MinibufferConfig {
buffer_key: "command".into(),
prompt: ":".into(),
color: MinibufColor::Yellow,
},
}
}
pub fn category_add() -> Self {
Self::CategoryAdd {
minibuf: MinibufferConfig {
buffer_key: "category".into(),
prompt: "new category: ".into(),
color: MinibufColor::Yellow,
},
}
}
pub fn item_add(category: String) -> Self {
let prompt = format!("add item to {category}: ");
Self::ItemAdd {
category,
minibuf: MinibufferConfig {
buffer_key: "item".into(),
prompt,
color: MinibufColor::Green,
},
}
}
pub fn export_prompt() -> Self {
Self::ExportPrompt {
minibuf: MinibufferConfig {
buffer_key: "export".into(),
prompt: "export path: ".into(),
color: MinibufColor::Yellow,
},
}
}
}
/// Document state slice: the workbook and its IO bookkeeping. Distinct from /// Document state slice: the workbook and its IO bookkeeping. Distinct from
/// `Workbook` itself (which is pure document semantics in `improvise-core`) /// `Workbook` itself (which is pure document semantics in `improvise-core`)
/// because `file_path` and `dirty` are persistence-layer concerns. /// because `file_path` and `dirty` are persistence-layer concerns.
@@ -361,86 +58,6 @@ impl Default for ModelState {
} }
} }
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
/// and other per-session state that does not persist to disk.
///
/// This is the **portable** half of the session: the one thing serialized
/// into the Snapshot a newly-connected client receives (improvise-q08). Two
/// properties gate membership, and both are load-bearing (improvise-h0b):
///
/// - **portable** — the type is plain data from the allowlisted crates, with
/// no shared-ownership containers. The `ViewState: Send` assertion in the
/// tests is the compile-time proxy.
/// - **snapshot-safe** — a fresh client inheriting the value is *correct*.
/// Staged uncommitted writes and half-open dialogs are not.
///
/// State failing either test lives on `App` instead, under the
/// **session-local** residue tag.
#[derive(Debug, Serialize, Deserialize)]
pub struct ViewState {
pub mode: AppMode,
pub status_msg: String,
/// Cursor and scroll position per view name. Keyed rather than single
/// so that switching views still restores where you were in each one —
/// the behaviour `View`-owned cursors gave, now per-client
/// (improvise-8uz). Absent key means "at the origin".
pub cursors: HashMap<String, ViewCursor>,
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
pub cat_panel_cursor: usize,
pub view_panel_cursor: usize,
pub formula_cursor: usize,
/// Yanked cell value for `p` paste
pub yanked: Option<CellValue>,
/// Tile select cursor (which category index is highlighted)
pub tile_cat_idx: usize,
/// View navigation history: views visited before the current one.
/// Pushed on SwitchView, popped by `<` (back).
pub view_back_stack: Vec<ViewFrame>,
/// Views that were "back-ed" from, available for forward navigation (`>`).
pub view_forward_stack: Vec<ViewFrame>,
/// Current page index in the Help screen (0-based).
pub help_page: usize,
/// Categories expanded in the category panel tree view.
pub expanded_cats: std::collections::HashSet<String>,
/// Named text buffers for text-entry modes
pub buffers: HashMap<String, String>,
/// The prefix key currently armed, for Emacs-style sequences (g→gg,
/// y→yy, Z→ZZ). Holds the key that opened the prefix, not the sub-keymap
/// it maps to: `KeymapSet` owns the keymaps, and `ViewState` is meant to
/// be portable, serializable state (improvise-jc8).
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
}
impl Default for ViewState {
fn default() -> Self {
Self {
mode: AppMode::Normal,
status_msg: String::new(),
cursors: HashMap::new(),
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
yanked: None,
tile_cat_idx: 0,
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
help_page: 0,
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_prefix: None,
}
}
}
/// Top-level TUI application state. Composed of two named slices and a /// Top-level TUI application state. Composed of two named slices and a
/// residue of fields that don't belong to either: /// residue of fields that don't belong to either:
/// ///
@@ -530,6 +147,7 @@ impl App {
// the caller must gather them explicitly. // the caller must gather them explicitly.
let none_cats = workbook.active_view().none_cats(); let none_cats = workbook.active_view().none_cats();
workbook.model.recompute_formulas(&none_cats); workbook.model.recompute_formulas(&none_cats);
let active_view_name = workbook.active_view.clone();
let layout = { let layout = {
let view = workbook.active_view(); let view = workbook.active_view();
GridLayout::with_frozen_records(&workbook.model, view, None) GridLayout::with_frozen_records(&workbook.model, view, None)
@@ -540,7 +158,10 @@ impl App {
file_path, file_path,
dirty: false, dirty: false,
}, },
view_state: ViewState::default(), view_state: ViewState {
active_view: active_view_name,
..ViewState::default()
},
wizard: None, wizard: None,
drill_state: None, drill_state: None,
last_autosave: Instant::now(), last_autosave: Instant::now(),
@@ -558,7 +179,7 @@ impl App {
/// This client's cursor in the active view. Absent means the origin, so /// This client's cursor in the active view. Absent means the origin, so
/// a view visited for the first time opens at (0, 0). /// a view visited for the first time opens at (0, 0).
pub fn cursor(&self) -> ViewCursor { pub fn cursor(&self) -> ViewCursor {
self.cursor_in(&self.model_state.workbook.active_view) self.view_state.cursor()
} }
/// This client's cursor in `view_name`. /// This client's cursor in `view_name`.
@@ -572,8 +193,27 @@ impl App {
/// Mutable cursor for the active view, created at the origin if absent. /// Mutable cursor for the active view, created at the origin if absent.
pub fn cursor_mut(&mut self) -> &mut ViewCursor { pub fn cursor_mut(&mut self) -> &mut ViewCursor {
let name = self.model_state.workbook.active_view.clone(); self.view_state.cursor_mut()
self.view_state.cursors.entry(name).or_default() }
/// Replace the whole document. The client's view slice is scoped to the
/// old one — its cursor map is keyed by that document's view names —
/// so it is reset to the incoming document's active view rather than
/// carried over (improvise-8uz, improvise-cqi).
pub fn install_workbook(&mut self, workbook: Workbook) {
self.view_state.active_view = workbook.active_view.clone();
self.view_state.cursors.clear();
self.model_state.workbook = workbook;
}
/// Point the view slice at `name` and switch the workbook to it. The
/// two copies of "which view is active" must move together: the
/// workbook's is the document's, `ViewState`'s is the client's, and
/// the client's is what keys the cursor map (improvise-cqi).
pub fn switch_to_view(&mut self, name: &str) {
if self.model_state.workbook.switch_view(name).is_ok() {
self.view_state.active_view = name.to_string();
}
} }
/// Send the active view's cursor back to the origin. Called when the /// Send the active view's cursor back to the origin. Called when the
@@ -792,6 +432,8 @@ impl App {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::cell::CellValue;
use std::collections::HashMap;
/// Every mode the user can be in, so the dispatch-method tests below /// Every mode the user can be in, so the dispatch-method tests below
/// exercise all arms. `Quit` is included even though it never renders. /// exercise all arms. `Quit` is included even though it never renders.
@@ -954,6 +596,43 @@ mod tests {
); );
} }
/// improvise-cqi: "which view is active" is now stored twice — on the
/// `Workbook` (the document's) and on `ViewState` (the client's, which
/// keys the cursor map). Every path that changes one must change the
/// other, or the cursor map is read under the wrong key. `App` owns
/// both writers: `switch_to_view` and `install_workbook`.
#[test]
fn the_two_active_view_names_never_diverge() {
let mut app = two_col_model();
app.model_state.workbook.create_view("Second");
fn agree(app: &App) {
assert_eq!(
app.view_state.active_view, app.model_state.workbook.active_view,
"view slice and document disagree about the active view"
);
}
agree(&app);
app.switch_to_view("Second");
agree(&app);
// A switch to a view that does not exist must move neither.
app.switch_to_view("Nonexistent");
agree(&app);
assert_eq!(app.view_state.active_view, "Second");
let mut incoming = Workbook::new("Other");
incoming.create_view("Elsewhere");
incoming.switch_view("Elsewhere").unwrap();
app.install_workbook(incoming);
agree(&app);
assert_eq!(app.view_state.active_view, "Elsewhere");
assert!(
app.view_state.cursors.is_empty(),
"a new document does not inherit the old one's cursors"
);
}
/// improvise-8uz: moving the cursor must leave the shared document /// improvise-8uz: moving the cursor must leave the shared document
/// untouched, so that one client scrolling cannot move another's /// untouched, so that one client scrolling cannot move another's
/// viewport. Before the move these three effects wrote into /// viewport. Before the move these three effects wrote into
@@ -1031,18 +710,18 @@ mod tests {
let mut app = two_col_model(); let mut app = two_col_model();
app.model_state.workbook.create_view("Second"); app.model_state.workbook.create_view("Second");
app.model_state.workbook.switch_view("Default").unwrap(); app.switch_to_view("Default");
app.cursor_mut().selected = (2, 1); app.cursor_mut().selected = (2, 1);
app.cursor_mut().row_offset = 1; app.cursor_mut().row_offset = 1;
app.model_state.workbook.switch_view("Second").unwrap(); app.switch_to_view("Second");
assert_eq!( assert_eq!(
app.cursor().selected, app.cursor().selected,
(0, 0), (0, 0),
"a different view starts at its own cursor, not the previous view's" "a different view starts at its own cursor, not the previous view's"
); );
app.model_state.workbook.switch_view("Default").unwrap(); app.switch_to_view("Default");
assert_eq!(app.cursor().selected, (2, 1)); assert_eq!(app.cursor().selected, (2, 1));
assert_eq!(app.cursor().row_offset, 1); assert_eq!(app.cursor().row_offset, 1);
} }
+6 -8
View File
@@ -335,7 +335,7 @@ impl Effect for SwitchView {
}); });
app.view_state.view_forward_stack.clear(); app.view_state.view_forward_stack.clear();
} }
let _ = app.model_state.workbook.switch_view(&self.0); app.switch_to_view(&self.0);
} }
fn kind(&self) -> EffectKind { fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry) EffectKind::Model(ModelChange::LayoutGeometry)
@@ -353,7 +353,7 @@ impl Effect for ViewBack {
view_name: current, view_name: current,
mode: app.view_state.mode.clone(), mode: app.view_state.mode.clone(),
}); });
let _ = app.model_state.workbook.switch_view(&frame.view_name); app.switch_to_view(&frame.view_name);
app.view_state.mode = frame.mode; app.view_state.mode = frame.mode;
} }
} }
@@ -373,7 +373,7 @@ impl Effect for ViewForward {
view_name: current, view_name: current,
mode: app.view_state.mode.clone(), mode: app.view_state.mode.clone(),
}); });
let _ = app.model_state.workbook.switch_view(&frame.view_name); app.switch_to_view(&frame.view_name);
app.view_state.mode = frame.mode; app.view_state.mode = frame.mode;
} }
} }
@@ -913,8 +913,7 @@ impl Effect for AcceptWizardModel {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
let built = app.wizard.as_mut().and_then(|w| w.take_built()); let built = app.wizard.as_mut().and_then(|w| w.take_built());
if let Some((workbook, warnings)) = built { if let Some((workbook, warnings)) = built {
app.model_state.workbook = workbook; app.install_workbook(workbook);
app.view_state.cursors.clear();
app.view_state.formula_cursor = 0; app.view_state.formula_cursor = 0;
app.model_state.dirty = true; app.model_state.dirty = true;
app.view_state.status_msg = if warnings.is_empty() { app.view_state.status_msg = if warnings.is_empty() {
@@ -993,8 +992,7 @@ impl Effect for LoadModel {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
match crate::persistence::load_from(&*app.storage, &self.0) { match crate::persistence::load_from(&*app.storage, &self.0) {
Ok(loaded) => { Ok(loaded) => {
app.model_state.workbook = loaded; app.install_workbook(loaded);
app.view_state.cursors.clear();
app.view_state.status_msg = format!("Loaded from {}", self.0.display()); app.view_state.status_msg = format!("Loaded from {}", self.0.display());
} }
Err(e) => { Err(e) => {
@@ -1124,7 +1122,7 @@ impl Effect for ImportJsonHeadless {
match pipeline.build_model_with_warnings() { match pipeline.build_model_with_warnings() {
Ok((new_workbook, build_warnings)) => { Ok((new_workbook, build_warnings)) => {
app.model_state.workbook = new_workbook; app.install_workbook(new_workbook);
warnings.extend(build_warnings); warnings.extend(build_warnings);
app.view_state.status_msg = if warnings.is_empty() { app.view_state.status_msg = if warnings.is_empty() {
"Imported successfully".to_string() "Imported successfully".to_string()
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "improvise-protocol"
version = "0.1.0-rc2"
edition = "2024"
description = "Wire vocabulary and portable session state shared by the improvise server and clients"
license = "Apache-2.0"
repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies]
improvise-core = { path = "../improvise-core" }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
+472
View File
@@ -0,0 +1,472 @@
//! Portable session state and the wire vocabulary between a server and a
//! client (improvise-cqi).
//!
//! Everything here crosses a process boundary, so the crate depends only on
//! `improvise-core` and `serde` — no terminal backend, no command layer, no
//! IO. `improvise-command` depends on *this*, never the reverse; that
//! direction is what makes the client able to hold the view half without
//! pulling in the host.
//!
//! Two things live here and it is worth being precise about which travels:
//!
//! - [`ViewState`] is a **projection of the command stream**, not a wire
//! message. The server keeps a mirror of each client's view state by
//! applying the same commands the client applies, through the shared
//! [`reduce_view`]. It is serialized at exactly one point — the bootstrap
//! [`Snapshot`] a newly-connected client folds later commands onto
//! (improvise-q08). Steady-state traffic is commands only.
//! - [`Command`] is the wire format, and travels in both directions: user
//! intent from client to server, projections from server to client.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod key;
mod reduce;
pub mod render;
pub use key::{KeyCode, KeyEvent, KeyModifiers};
pub use reduce::{Command, HelpPage, reduce_view};
pub use render::{CellDisplay, RenderCache, Snapshot};
use improvise_core::model::cell::CellValue;
/// Number of pages in the Help screen. Lives here (with the `help_page`
/// state it bounds) so help-page effects stay terminal-independent; the
/// help renderer in the root crate imports it.
pub const HELP_PAGE_COUNT: usize = 5;
/// Where one client is looking within one view: the selected cell and the
/// scroll offsets that keep it on screen.
///
/// This is per-client state (improvise-8uz). It used to live on `View`,
/// inside the shared workbook, which meant one client scrolling moved
/// everyone's viewport. `View` no longer carries it at all — nothing
/// persisted it, so there was no saved-position behaviour to preserve.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewCursor {
/// Selected cell, as (row index, column index).
pub selected: (usize, usize),
pub row_offset: usize,
pub col_offset: usize,
}
/// A saved view+mode pair for the navigation stack.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewFrame {
pub view_name: String,
pub mode: AppMode,
}
/// Accent color for a minibuffer prompt. Semantic palette only — the
/// rendering layer maps each variant to a concrete terminal color, so
/// this type carries no rendering-framework dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MinibufColor {
Green,
Cyan,
Yellow,
}
/// Display configuration for the bottom-bar minibuffer.
/// Carried structurally by text-entry `AppMode` variants.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MinibufferConfig {
pub buffer_key: String,
pub prompt: String,
pub color: MinibufColor,
}
/// One of the three side panels. Paired with [`AppMode::panel`], which names
/// the panel a mode is focused in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Panel {
Formula,
Category,
View,
}
impl Panel {
/// Every panel, in the order their toggle keys are bound.
pub const ALL: [Panel; 3] = [Panel::Formula, Panel::Category, Panel::View];
/// The name commands use for this panel — the spelling `parse_panel`
/// accepts, so `toggle-panel-and-focus` and `move-panel-cursor` args can
/// be generated rather than spelled out per panel.
pub fn name(self) -> &'static str {
match self {
Panel::Formula => "formula",
Panel::Category => "category",
Panel::View => "view",
}
}
/// The key that toggles focus to this panel, bound in Normal mode and in
/// every panel's own keymap.
pub fn key(self) -> char {
match self {
Panel::Formula => 'F',
Panel::Category => 'C',
Panel::View => 'V',
}
}
/// The mode that focuses this panel. Inverse of [`AppMode::panel`] for the
/// three top-level panel modes (the sub-modes — `FormulaEdit`,
/// `CategoryAdd`, `ItemAdd` — map back to their panel but not forwards).
pub fn mode(self) -> AppMode {
match self {
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AppMode {
Normal,
Editing {
minibuf: MinibufferConfig,
},
FormulaEdit {
minibuf: MinibufferConfig,
},
FormulaPanel,
CategoryPanel,
/// Quick-add a new category: Enter adds and stays open, Esc closes.
CategoryAdd {
minibuf: MinibufferConfig,
},
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
ItemAdd {
category: String,
minibuf: MinibufferConfig,
},
ViewPanel,
TileSelect,
ImportWizard,
ExportPrompt {
minibuf: MinibufferConfig,
},
/// Vim-style `:` command line
CommandMode {
minibuf: MinibufferConfig,
},
Help,
Quit,
/// Records-mode normal: inherits from Normal with records-specific bindings.
RecordsNormal,
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
RecordsEditing {
minibuf: MinibufferConfig,
},
}
impl AppMode {
/// Short uppercase name shown in the title bar.
pub fn label(&self) -> &'static str {
match self {
Self::Normal => "NORMAL",
Self::Editing { .. } => "INSERT",
Self::FormulaEdit { .. } => "FORMULA",
Self::FormulaPanel => "FORMULAS",
Self::CategoryPanel => "CATEGORIES",
Self::CategoryAdd { .. } => "NEW CATEGORY",
Self::ItemAdd { .. } => "ADD ITEMS",
Self::ViewPanel => "VIEWS",
Self::TileSelect => "TILES",
Self::ImportWizard => "IMPORT",
Self::ExportPrompt { .. } => "EXPORT",
Self::CommandMode { .. } => "COMMAND",
Self::Help => "HELP",
Self::Quit => "QUIT",
Self::RecordsNormal => "RECORDS",
Self::RecordsEditing { .. } => "RECORDS INSERT",
}
}
/// Context-sensitive keybinding hint for the status bar.
///
/// `ExportPrompt` and `Quit` deliberately show nothing: `Quit` is never
/// rendered, and `ExportPrompt` has no hint text yet (improvise-1c9).
pub fn hint_text(&self) -> &'static str {
match self {
Self::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
Self::Editing { .. } | Self::RecordsEditing { .. } => {
"Enter:commit Tab:commit+right Esc:cancel"
}
Self::RecordsNormal => "hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd",
Self::FormulaPanel => "n:new d:delete jk:nav Esc:back",
Self::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
Self::CategoryPanel => {
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
}
Self::CategoryAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type a category name"
}
Self::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
Self::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
Self::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
Self::CommandMode { .. } => {
":q quit :w save :import :add-cat :formula :show-item :help"
}
Self::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
Self::Help => "h/l:pages q/Esc:close",
Self::ExportPrompt { .. } | Self::Quit => "",
}
}
/// The side panel this mode is focused in, if any. A panel's sub-modes
/// count as that panel, so the panel keeps its active styling while the
/// user types into its minibuffer.
pub fn panel(&self) -> Option<Panel> {
match self {
Self::FormulaPanel | Self::FormulaEdit { .. } => Some(Panel::Formula),
Self::CategoryPanel | Self::CategoryAdd { .. } | Self::ItemAdd { .. } => {
Some(Panel::Category)
}
Self::ViewPanel => Some(Panel::View),
Self::Normal
| Self::Editing { .. }
| Self::TileSelect
| Self::ImportWizard
| Self::ExportPrompt { .. }
| Self::CommandMode { .. }
| Self::Help
| Self::Quit
| Self::RecordsNormal
| Self::RecordsEditing { .. } => None,
}
}
/// Extract the minibuffer config from text-entry modes, if present.
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
match self {
Self::Editing { minibuf, .. }
| Self::RecordsEditing { minibuf, .. }
| Self::FormulaEdit { minibuf, .. }
| Self::CommandMode { minibuf, .. }
| Self::CategoryAdd { minibuf, .. }
| Self::ItemAdd { minibuf, .. }
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
_ => None,
}
}
/// True for any cell-editing mode (normal or records).
pub fn is_editing(&self) -> bool {
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
}
pub fn editing() -> Self {
Self::Editing {
minibuf: MinibufferConfig {
buffer_key: "edit".into(),
prompt: "edit: ".into(),
color: MinibufColor::Green,
},
}
}
pub fn records_editing() -> Self {
Self::RecordsEditing {
minibuf: MinibufferConfig {
buffer_key: "edit".into(),
prompt: "edit: ".into(),
color: MinibufColor::Green,
},
}
}
/// True when this mode is a records-mode variant.
pub fn is_records(&self) -> bool {
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
}
pub fn formula_edit() -> Self {
Self::FormulaEdit {
minibuf: MinibufferConfig {
buffer_key: "formula".into(),
prompt: "formula: ".into(),
color: MinibufColor::Cyan,
},
}
}
pub fn command_mode() -> Self {
Self::CommandMode {
minibuf: MinibufferConfig {
buffer_key: "command".into(),
prompt: ":".into(),
color: MinibufColor::Yellow,
},
}
}
pub fn category_add() -> Self {
Self::CategoryAdd {
minibuf: MinibufferConfig {
buffer_key: "category".into(),
prompt: "new category: ".into(),
color: MinibufColor::Yellow,
},
}
}
pub fn item_add(category: String) -> Self {
let prompt = format!("add item to {category}: ");
Self::ItemAdd {
category,
minibuf: MinibufferConfig {
buffer_key: "item".into(),
prompt,
color: MinibufColor::Green,
},
}
}
pub fn export_prompt() -> Self {
Self::ExportPrompt {
minibuf: MinibufferConfig {
buffer_key: "export".into(),
prompt: "export path: ".into(),
color: MinibufColor::Yellow,
},
}
}
}
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
/// and other per-session state that does not persist to disk.
///
/// This is the **portable** half of the session: the one thing serialized
/// into the Snapshot a newly-connected client receives (improvise-q08). Two
/// properties gate membership, and both are load-bearing (improvise-h0b):
///
/// - **portable** — the type is plain data from the allowlisted crates, with
/// no shared-ownership containers. The `ViewState: Send` assertion in the
/// tests is the compile-time proxy.
/// - **snapshot-safe** — a fresh client inheriting the value is *correct*.
/// Staged uncommitted writes and half-open dialogs are not.
///
/// State failing either test lives on `App` instead, under the
/// **session-local** residue tag.
#[derive(Debug, Serialize, Deserialize)]
pub struct ViewState {
pub mode: AppMode,
pub status_msg: String,
/// Which view this client is looking at. Duplicated from
/// `Workbook::active_view` on purpose: `reduce_view` runs on a client
/// that holds no `Workbook`, and `cursors` is keyed by view name, so
/// the portable half has to carry its own key (improvise-cqi). The
/// host keeps the two in step; whether they are *allowed* to diverge —
/// two clients on different views — is improvise-g0f.
pub active_view: String,
/// Cursor and scroll position per view name. Keyed rather than single
/// so that switching views still restores where you were in each one —
/// the behaviour `View`-owned cursors gave, now per-client
/// (improvise-8uz). Absent key means "at the origin".
pub cursors: HashMap<String, ViewCursor>,
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
pub cat_panel_cursor: usize,
pub view_panel_cursor: usize,
pub formula_cursor: usize,
/// Yanked cell value for `p` paste
pub yanked: Option<CellValue>,
/// Tile select cursor (which category index is highlighted)
pub tile_cat_idx: usize,
/// View navigation history: views visited before the current one.
/// Pushed on SwitchView, popped by `<` (back).
pub view_back_stack: Vec<ViewFrame>,
/// Views that were "back-ed" from, available for forward navigation (`>`).
pub view_forward_stack: Vec<ViewFrame>,
/// Current page index in the Help screen (0-based).
pub help_page: usize,
/// Categories expanded in the category panel tree view.
pub expanded_cats: std::collections::HashSet<String>,
/// Named text buffers for text-entry modes
pub buffers: HashMap<String, String>,
/// The prefix key currently armed, for Emacs-style sequences (g→gg,
/// y→yy, Z→ZZ). Holds the key that opened the prefix, not the sub-keymap
/// it maps to: `KeymapSet` owns the keymaps, and `ViewState` is meant to
/// be portable, serializable state (improvise-jc8).
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
}
impl Default for ViewState {
fn default() -> Self {
Self {
mode: AppMode::Normal,
status_msg: String::new(),
active_view: "Default".to_string(),
cursors: HashMap::new(),
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
yanked: None,
tile_cat_idx: 0,
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
help_page: 0,
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_prefix: None,
}
}
}
impl ViewState {
/// This client's cursor in the active view. Absent means the origin, so
/// a view visited for the first time opens at (0, 0).
pub fn cursor(&self) -> ViewCursor {
self.cursors
.get(&self.active_view)
.copied()
.unwrap_or_default()
}
/// Mutable cursor for the active view, created at the origin if absent.
pub fn cursor_mut(&mut self) -> &mut ViewCursor {
self.cursors.entry(self.active_view.clone()).or_default()
}
}
#[cfg(test)]
mod boundary_tests {
/// improvise-cqi AC 2: this crate crosses a process boundary, so it must
/// not drag a terminal backend with it. A `cargo tree` check would be
/// external; this is the part the compiler can hold — the types here
/// resolve with only `improvise-core` and `serde` in scope, and adding
/// a `ratatui` or `crossterm` dependency would have to be written into
/// Cargo.toml deliberately rather than arriving through a `use`.
///
/// The CI `wasm` job is the other half: a crate that pulled in
/// crossterm would stop building for wasm32.
#[test]
fn the_wire_types_need_nothing_but_core_and_serde() {
fn portable<T: serde::Serialize + serde::de::DeserializeOwned>() {}
portable::<super::ViewState>();
portable::<super::AppMode>();
portable::<super::ViewCursor>();
portable::<super::ViewFrame>();
portable::<super::Panel>();
portable::<super::Command>();
portable::<super::RenderCache>();
portable::<super::CellDisplay>();
portable::<super::Snapshot>();
portable::<super::KeyCode>();
portable::<super::KeyModifiers>();
}
}
+377
View File
@@ -0,0 +1,377 @@
//! The wire vocabulary, and the fold that applies it to a client's view.
//!
//! `Command` is not a fresh invention: its client→server half is the set of
//! mutations that actually reach [`ViewState`] — every `EffectKind::ViewOnly`
//! effect in `improvise-command` whose target is the portable slice. Effects
//! that touch host-local state (the import wizard, staged drill edits, the
//! storage backend, the layout cache) are deliberately absent: they are not
//! portable, which is why improvise-h0b moved them off `ViewState`.
//!
//! The server→client half carries projections: already-formatted display
//! values and the labels that frame them (improvise-cqq).
use serde::{Deserialize, Serialize};
use improvise_core::model::cell::CellValue;
use crate::render::{CellDisplay, RenderCache};
use crate::{AppMode, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
/// One message on the wire, in either direction.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Command {
// ── client → server: view-slice intent ──────────────────────────
/// Move the cursor within the active view.
SetSelected {
row: usize,
col: usize,
},
SetRowOffset(usize),
SetColOffset(usize),
ChangeMode(AppMode),
SetStatus(String),
SetYanked(Option<CellValue>),
SetSearchQuery(String),
SetSearchMode(bool),
SetBuffer {
name: String,
value: String,
},
ClearBuffer(String),
SetTileCatIdx(usize),
SetPanelOpen {
panel: Panel,
open: bool,
},
SetPanelCursor {
panel: Panel,
cursor: usize,
},
ToggleCatExpand(String),
HelpPage(HelpPage),
/// Arm a prefix key, or clear the armed one. Held as the *address* of
/// the prefix rather than the sub-keymap it names, so the keymaps stay
/// on the host side (improvise-jc8).
SetTransientPrefix(Option<(KeyCode, KeyModifiers)>),
/// The active view changed. Carries the name because a client's cursor
/// map is keyed by it (improvise-8uz).
SwitchView(String),
// ── server → client: projections ────────────────────────────────
/// Display values for cells, by viewport position.
CellsUpdated(Vec<((usize, usize), CellDisplay)>),
RowLabelsChanged(Vec<String>),
ColLabelsChanged(Vec<String>),
ColWidthsChanged(Vec<u16>),
/// The grid's shape changed and the client's cache is stale; a
/// `RowLabelsChanged`/`ColLabelsChanged` pair follows.
ViewportInvalidated,
}
/// Which way a help-page command moves. An enum rather than three variants
/// so the bounds check has one home.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelpPage {
Next,
Prev,
Set(usize),
}
/// Apply one command to a client's view state and render cache.
///
/// The same function runs on both ends: the client folds it to draw, and
/// the server folds it over its mirror of that client's view state, so the
/// two cannot drift (improvise-cqq, improvise-m91). It is total and
/// deterministic — no IO, no clock, no failure.
pub fn reduce_view(view: &mut ViewState, cache: &mut RenderCache, cmd: &Command) {
match cmd {
Command::SetSelected { row, col } => {
view.cursor_mut().selected = (*row, *col);
}
Command::SetRowOffset(n) => view.cursor_mut().row_offset = *n,
Command::SetColOffset(n) => view.cursor_mut().col_offset = *n,
Command::ChangeMode(mode) => view.mode = mode.clone(),
Command::SetStatus(s) => view.status_msg = s.clone(),
Command::SetYanked(v) => view.yanked = v.clone(),
Command::SetSearchQuery(q) => view.search_query = q.clone(),
Command::SetSearchMode(on) => view.search_mode = *on,
Command::SetBuffer { name, value } => {
view.buffers.insert(name.clone(), value.clone());
}
Command::ClearBuffer(name) => {
view.buffers.remove(name);
}
Command::SetTileCatIdx(i) => view.tile_cat_idx = *i,
Command::SetPanelOpen { panel, open } => match panel {
Panel::Formula => view.formula_panel_open = *open,
Panel::Category => view.category_panel_open = *open,
Panel::View => view.view_panel_open = *open,
},
Command::SetPanelCursor { panel, cursor } => match panel {
Panel::Formula => view.formula_cursor = *cursor,
Panel::Category => view.cat_panel_cursor = *cursor,
Panel::View => view.view_panel_cursor = *cursor,
},
Command::ToggleCatExpand(cat) => {
if !view.expanded_cats.remove(cat) {
view.expanded_cats.insert(cat.clone());
}
}
Command::HelpPage(step) => {
view.help_page = match step {
HelpPage::Next => (view.help_page + 1) % HELP_PAGE_COUNT,
HelpPage::Prev => (view.help_page + HELP_PAGE_COUNT - 1) % HELP_PAGE_COUNT,
HelpPage::Set(n) => (*n).min(HELP_PAGE_COUNT - 1),
};
}
Command::SetTransientPrefix(p) => view.transient_prefix = *p,
Command::SwitchView(name) => view.active_view = name.clone(),
Command::CellsUpdated(changes) => {
for ((row, col), display) in changes {
if let Some(slot) = cache.cells.get_mut(*row).and_then(|r| r.get_mut(*col)) {
*slot = display.clone();
}
}
}
Command::RowLabelsChanged(labels) => {
cache.row_labels = labels.clone();
cache.fit_to_labels();
}
Command::ColLabelsChanged(labels) => {
cache.col_labels = labels.clone();
cache.fit_to_labels();
}
Command::ColWidthsChanged(widths) => cache.col_widths = widths.clone(),
Command::ViewportInvalidated => {
// Drop the contents but keep the frame: labels arrive next, and
// rendering a stale grid under fresh labels is worse than blank.
for row in &mut cache.cells {
for cell in row {
*cell = CellDisplay::default();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Panel, ViewCursor};
fn fold(cmds: &[Command]) -> (ViewState, RenderCache) {
let mut view = ViewState::default();
let mut cache = RenderCache::default();
for cmd in cmds {
reduce_view(&mut view, &mut cache, cmd);
}
(view, cache)
}
#[test]
fn cursor_commands_land_in_the_active_views_entry() {
let (view, _) = fold(&[
Command::SwitchView("Second".into()),
Command::SetSelected { row: 3, col: 4 },
Command::SetRowOffset(2),
Command::SetColOffset(1),
]);
assert_eq!(
view.cursors.get("Second").copied(),
Some(ViewCursor {
selected: (3, 4),
row_offset: 2,
col_offset: 1,
})
);
assert!(
!view.cursors.contains_key("Default"),
"the view we switched away from never gained an entry"
);
}
/// The point of keying the cursor map: switching back restores where
/// you were, and the client can do that without a `Workbook`.
#[test]
fn switching_views_restores_each_cursor() {
let (view, _) = fold(&[
Command::SetSelected { row: 1, col: 1 },
Command::SwitchView("Second".into()),
Command::SetSelected { row: 9, col: 9 },
Command::SwitchView("Default".into()),
]);
assert_eq!(view.cursor().selected, (1, 1));
}
#[test]
fn panel_commands_address_the_named_panel_only() {
let (view, _) = fold(&[
Command::SetPanelOpen {
panel: Panel::Category,
open: true,
},
Command::SetPanelCursor {
panel: Panel::Category,
cursor: 4,
},
]);
assert!(view.category_panel_open);
assert_eq!(view.cat_panel_cursor, 4);
assert!(!view.formula_panel_open && !view.view_panel_open);
assert_eq!(view.formula_cursor, 0);
assert_eq!(view.view_panel_cursor, 0);
}
#[test]
fn toggle_cat_expand_is_involutive() {
let (view, _) = fold(&[
Command::ToggleCatExpand("Region".into()),
Command::ToggleCatExpand("Region".into()),
]);
assert!(view.expanded_cats.is_empty());
}
#[test]
fn help_pages_wrap_in_both_directions_and_set_clamps() {
let last = HELP_PAGE_COUNT - 1;
let (back, _) = fold(&[Command::HelpPage(HelpPage::Prev)]);
assert_eq!(back.help_page, last, "page 0 wraps backwards to the last");
let forward: Vec<Command> = (0..HELP_PAGE_COUNT)
.map(|_| Command::HelpPage(HelpPage::Next))
.collect();
let (wrapped, _) = fold(&forward);
assert_eq!(wrapped.help_page, 0, "a full lap returns to the first");
let (clamped, _) = fold(&[Command::HelpPage(HelpPage::Set(99))]);
assert_eq!(clamped.help_page, last, "out of range clamps, never panics");
}
#[test]
fn buffers_are_set_and_cleared_by_name() {
let (view, _) = fold(&[
Command::SetBuffer {
name: "edit".into(),
value: "hi".into(),
},
Command::SetBuffer {
name: "command".into(),
value: "w".into(),
},
Command::ClearBuffer("edit".into()),
]);
assert_eq!(view.buffers.get("command").map(String::as_str), Some("w"));
assert!(!view.buffers.contains_key("edit"));
}
#[test]
fn label_changes_resize_the_cell_grid() {
let (_, cache) = fold(&[
Command::RowLabelsChanged(vec!["r0".into(), "r1".into()]),
Command::ColLabelsChanged(vec!["c0".into(), "c1".into(), "c2".into()]),
]);
assert_eq!(cache.cells.len(), 2);
assert!(cache.cells.iter().all(|r| r.len() == 3));
}
#[test]
fn cells_updated_writes_by_viewport_position() {
let (_, cache) = fold(&[
Command::RowLabelsChanged(vec!["r0".into(), "r1".into()]),
Command::ColLabelsChanged(vec!["c0".into(), "c1".into()]),
Command::CellsUpdated(vec![((1, 0), CellDisplay::number("42"))]),
]);
assert_eq!(cache.cell(1, 0).unwrap().text, "42");
assert!(cache.cell(1, 0).unwrap().numeric);
assert_eq!(cache.cell(0, 0).unwrap().text, "");
}
/// A projection that names a cell outside the current viewport must be
/// dropped, not panic: labels and cell updates race on the wire, and a
/// client that crashes on a stale index is a client that crashes.
#[test]
fn cells_updated_ignores_positions_outside_the_cache() {
let (_, cache) = fold(&[
Command::RowLabelsChanged(vec!["r0".into()]),
Command::ColLabelsChanged(vec!["c0".into()]),
Command::CellsUpdated(vec![((99, 99), CellDisplay::text("ghost"))]),
]);
assert_eq!(cache.cell(0, 0).unwrap().text, "");
}
#[test]
fn viewport_invalidated_blanks_contents_but_keeps_the_frame() {
let (_, cache) = fold(&[
Command::RowLabelsChanged(vec!["r0".into()]),
Command::ColLabelsChanged(vec!["c0".into()]),
Command::CellsUpdated(vec![((0, 0), CellDisplay::number("7"))]),
Command::ViewportInvalidated,
]);
assert_eq!(cache.cell(0, 0).unwrap().text, "");
assert_eq!(cache.row_labels, vec!["r0".to_string()]);
}
/// Both ends fold the same commands, so the same stream must produce
/// the same state — otherwise the server's mirror drifts from the
/// client it is mirroring (improvise-m91).
#[test]
fn folding_the_same_stream_twice_gives_the_same_state() {
let stream = [
Command::SwitchView("Second".into()),
Command::SetSelected { row: 2, col: 2 },
Command::ToggleCatExpand("Region".into()),
Command::SetSearchQuery("north".into()),
Command::HelpPage(HelpPage::Next),
Command::RowLabelsChanged(vec!["r".into()]),
Command::ColLabelsChanged(vec!["c".into()]),
Command::CellsUpdated(vec![((0, 0), CellDisplay::number("1"))]),
];
let (a_view, a_cache) = fold(&stream);
let (b_view, b_cache) = fold(&stream);
assert_eq!(format!("{a_view:?}"), format!("{b_view:?}"));
assert_eq!(a_cache, b_cache);
}
#[test]
fn every_command_round_trips_through_json() {
let stream = [
Command::SetSelected { row: 1, col: 2 },
Command::SetRowOffset(3),
Command::SetColOffset(4),
Command::ChangeMode(AppMode::item_add("Region".into())),
Command::SetStatus("saved".into()),
Command::SetYanked(Some(CellValue::Number(1.5))),
Command::SetSearchQuery("q".into()),
Command::SetSearchMode(true),
Command::SetBuffer {
name: "edit".into(),
value: "v".into(),
},
Command::ClearBuffer("edit".into()),
Command::SetTileCatIdx(2),
Command::SetPanelOpen {
panel: Panel::View,
open: true,
},
Command::SetPanelCursor {
panel: Panel::Formula,
cursor: 1,
},
Command::ToggleCatExpand("Region".into()),
Command::HelpPage(HelpPage::Set(2)),
Command::SetTransientPrefix(Some((KeyCode::Char('g'), KeyModifiers::CONTROL))),
Command::SwitchView("Second".into()),
Command::CellsUpdated(vec![((0, 1), CellDisplay::error("#div/0"))]),
Command::RowLabelsChanged(vec!["r".into()]),
Command::ColLabelsChanged(vec!["c".into()]),
Command::ColWidthsChanged(vec![8, 12]),
Command::ViewportInvalidated,
];
for cmd in &stream {
let json = serde_json::to_string(cmd).expect("serializes");
let back: Command = serde_json::from_str(&json).expect("deserializes");
assert_eq!(&back, cmd, "round trip changed {cmd:?}");
}
}
}
+90
View File
@@ -0,0 +1,90 @@
//! What a client needs in order to draw, without the model.
//!
//! A client holds no `Model`: it is sent *display* values, already
//! formatted, and never recomputes a formula. `RenderCache` is that local
//! copy of the visible grid, rebuilt by folding projection commands
//! (improvise-cqq). It is serialized only to ride in the bootstrap
//! [`Snapshot`].
use serde::{Deserialize, Serialize};
use crate::ViewState;
/// One cell as the client draws it: the text, plus the little that changes
/// how it is drawn. Deliberately not a `CellValue` — by the time a cell
/// reaches a client the number formatting has already happened, and
/// re-deriving it client-side is how the two ends drift apart.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CellDisplay {
pub text: String,
/// Right-align numerics, left-align text — decided by the server, which
/// is the side that knows which it was.
pub numeric: bool,
/// A formula evaluation error, rendered distinctly.
pub error: bool,
}
impl CellDisplay {
pub fn text(s: impl Into<String>) -> Self {
Self {
text: s.into(),
..Self::default()
}
}
pub fn number(s: impl Into<String>) -> Self {
Self {
text: s.into(),
numeric: true,
error: false,
}
}
pub fn error(s: impl Into<String>) -> Self {
Self {
text: s.into(),
numeric: false,
error: true,
}
}
}
/// The client's copy of the visible grid.
///
/// Indexed by position within the *viewport*, not by `CellKey`: the client
/// does not know the model's coordinates, and the server is free to change
/// what a row means as long as it says so.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenderCache {
pub row_labels: Vec<String>,
pub col_labels: Vec<String>,
pub col_widths: Vec<u16>,
/// Row-major, `row_labels.len()` rows of `col_labels.len()` cells.
pub cells: Vec<Vec<CellDisplay>>,
}
impl RenderCache {
pub fn cell(&self, row: usize, col: usize) -> Option<&CellDisplay> {
self.cells.get(row)?.get(col)
}
/// Resize to match the current labels, preserving what still fits.
/// Called after any command that changes the grid's shape.
pub fn fit_to_labels(&mut self) {
let cols = self.col_labels.len();
self.cells.resize_with(self.row_labels.len(), Vec::new);
for row in &mut self.cells {
row.resize_with(cols, CellDisplay::default);
}
}
}
/// What a client receives on connect: a value to fold later commands onto.
///
/// The *only* point at which `ViewState` is serialized (improvise-q08).
/// Steady-state traffic after this is commands only.
#[derive(Debug, Serialize, Deserialize)]
pub struct Snapshot {
pub view: ViewState,
pub cache: RenderCache,
}
+26
View File
@@ -522,3 +522,29 @@ decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait
work_done('improvise-i34',feature,'Storage gained list/delete, implemented for PhysicalFs and MemoryFs with a shared storage_contract test both satisfy. OpfsFs added behind cfg(target_arch=wasm32) using web-sys sync access handles, with open/attach/refresh/commit_deletes as the async seam. Compile- and clippy-clean for wasm32.'). work_done('improvise-i34',feature,'Storage gained list/delete, implemented for PhysicalFs and MemoryFs with a shared storage_contract test both satisfy. OpfsFs added behind cfg(target_arch=wasm32) using web-sys sync access handles, with open/attach/refresh/commit_deletes as the async seam. Compile- and clippy-clean for wasm32.').
work_followup('improvise-i34','browser_verification','OpfsFs is compile-checked only -- no browser in this environment. storage_contract in storage.rs is the sequence it must satisfy. User will verify at the web MVP.'). work_followup('improvise-i34','browser_verification','OpfsFs is compile-checked only -- no browser in this environment. storage_contract in storage.rs is the sequence it must satisfy. User will verify at the web MVP.').
doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to'). doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to').
% ---------------------------------------------------------------------------
% improvise-cqi: the improvise-protocol crate.
% ---------------------------------------------------------------------------
crate(improvise_protocol,'crates/improvise-protocol','wire vocabulary + portable session slice: Command, ViewState, RenderCache, reduce_view').
layer(protocol,improvise_protocol).
crate_depends_on(improvise_protocol,improvise_core).
crate_depends_on(improvise_command,improvise_protocol).
forbidden(improvise_protocol,ui).
forbidden(improvise_protocol,io).
forbidden(improvise_protocol,commands).
wasm_bundle_crate(improvise_protocol).
% What travels, and what merely folds. Confusing these inverts the design.
wire_carries(command,both_directions,'user intent client->server, projections server->client').
wire_carries(snapshot,bootstrap_only,'the ONE point ViewState is serialized: a value for a fresh client to fold later commands onto').
derived_by_fold(view_state,command_stream,'both ends run reduce_view over the same commands, so the server mirror cannot drift from the client').
derived_by_fold(render_cache,projection_commands,'local to each client; serialized only to ride in the bootstrap Snapshot').
decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.').
decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.').
kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of "which view is active". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.').
work_done('improvise-cqi',feature,'crates/improvise-protocol created. ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig, MinibufColor, HELP_PAGE_COUNT and the key types moved there; improvise-command re-exports them so no caller path changed. Command + reduce_view + RenderCache/CellDisplay/Snapshot added with 15 tests. Added to the CI wasm job.').
work_unblocked('improvise-cqi','improvise-gxi','reduce_full can now live in improvise-command taking a protocol Command, with no cycle').
doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against').