Initial implementation of Improvise TUI

Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-20 21:11:14 -07:00
parent 0ba39672d3
commit eae00522e2
35 changed files with 5413 additions and 0 deletions

219
src/command/dispatch.rs Normal file
View File

@ -0,0 +1,219 @@
use anyhow::Result;
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::formula::parse_formula;
use crate::view::Axis;
use crate::persistence;
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
use super::types::{CellValueArg, Command, CommandResult};
/// Execute a command against the model, returning a result.
/// This is the single authoritative mutation path used by both the TUI and headless modes.
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
match cmd {
Command::AddCategory { name } => {
match model.add_category(name) {
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::AddItem { category, item } => {
match model.category_mut(category) {
Some(cat) => { cat.add_item(item); CommandResult::ok() }
None => CommandResult::err(format!("Category '{category}' not found")),
}
}
Command::AddItemInGroup { category, item, group } => {
match model.category_mut(category) {
Some(cat) => { cat.add_item_in_group(item, group); CommandResult::ok() }
None => CommandResult::err(format!("Category '{category}' not found")),
}
}
Command::SetCell { coords, value } => {
let kv: Vec<(String, String)> = coords.iter()
.map(|pair| (pair[0].clone(), pair[1].clone()))
.collect();
// Ensure items exist
for (cat_name, item_name) in &kv {
if let Some(cat) = model.category_mut(cat_name) {
cat.add_item(item_name);
}
}
let key = CellKey::new(kv);
let cell_value = match value {
CellValueArg::Number { number } => CellValue::Number(*number),
CellValueArg::Text { text } => CellValue::Text(text.clone()),
};
model.set_cell(key, cell_value);
CommandResult::ok()
}
Command::ClearCell { coords } => {
let kv: Vec<(String, String)> = coords.iter()
.map(|pair| (pair[0].clone(), pair[1].clone()))
.collect();
let key = CellKey::new(kv);
model.set_cell(key, CellValue::Empty);
CommandResult::ok()
}
Command::AddFormula { raw, target_category } => {
match parse_formula(raw, target_category) {
Ok(formula) => {
// Ensure the target item exists in the target category
let target = formula.target.clone();
let cat_name = formula.target_category.clone();
if let Some(cat) = model.category_mut(&cat_name) {
cat.add_item(&target);
}
model.add_formula(formula);
CommandResult::ok_msg(format!("Formula '{raw}' added"))
}
Err(e) => CommandResult::err(format!("Parse error: {e}")),
}
}
Command::RemoveFormula { target } => {
model.remove_formula(target);
CommandResult::ok()
}
Command::CreateView { name } => {
model.create_view(name);
CommandResult::ok()
}
Command::DeleteView { name } => {
match model.delete_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::SwitchView { name } => {
match model.switch_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::SetAxis { category, axis } => {
let ax = match axis.to_lowercase().as_str() {
"row" | "rows" => Axis::Row,
"column" | "col" | "columns" => Axis::Column,
"page" | "pages" | "filter" => Axis::Page,
other => return CommandResult::err(format!("Unknown axis '{other}'")),
};
match model.active_view_mut() {
Some(view) => { view.set_axis(category, ax); CommandResult::ok() }
None => CommandResult::err("No active view"),
}
}
Command::SetPageSelection { category, item } => {
match model.active_view_mut() {
Some(view) => { view.set_page_selection(category, item); CommandResult::ok() }
None => CommandResult::err("No active view"),
}
}
Command::ToggleGroup { category, group } => {
match model.active_view_mut() {
Some(view) => { view.toggle_group_collapse(category, group); CommandResult::ok() }
None => CommandResult::err("No active view"),
}
}
Command::Save { path } => {
match persistence::save(model, std::path::Path::new(path)) {
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::Load { path } => {
match persistence::load(std::path::Path::new(path)) {
Ok(loaded) => {
*model = loaded;
CommandResult::ok_msg(format!("Loaded from {path}"))
}
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::ExportCsv { path } => {
let view_name = model.active_view.clone();
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
Ok(_) => CommandResult::ok_msg(format!("Exported to {path}")),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::ImportJson { path, model_name, array_path } => {
import_json_headless(model, path, model_name.as_deref(), array_path.as_deref())
}
}
}
fn import_json_headless(
model: &mut Model,
path: &str,
model_name: Option<&str>,
array_path: Option<&str>,
) -> CommandResult {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
};
let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => return CommandResult::err(format!("No array at path '{ap}'")),
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
// Find first array
let paths = crate::import::analyzer::find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => return CommandResult::err("Could not extract records array"),
}
} else {
return CommandResult::err("No array found in JSON");
}
};
let proposals = analyze_records(&records);
// Auto-accept all and build
let wizard = crate::import::wizard::ImportWizard {
state: crate::import::wizard::WizardState::NameModel,
raw: value,
array_paths: vec![],
selected_path: array_path.unwrap_or("").to_string(),
records,
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
model_name: model_name.unwrap_or("Imported Model").to_string(),
cursor: 0,
message: None,
};
match wizard.build_model() {
Ok(new_model) => {
*model = new_model;
CommandResult::ok_msg("JSON imported successfully")
}
Err(e) => CommandResult::err(e.to_string()),
}
}

12
src/command/mod.rs Normal file
View File

@ -0,0 +1,12 @@
//! Command layer — all model mutations go through this layer so they can be
//! replayed, scripted, and tested without the TUI.
//!
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
//! The headless CLI (--cmd / --script) routes through here, and the TUI
//! App also calls dispatch() for every user action that mutates state.
pub mod dispatch;
pub mod types;
pub use types::{Command, CommandResult};
pub use dispatch::dispatch;

99
src/command/types.rs Normal file
View File

@ -0,0 +1,99 @@
use serde::{Deserialize, Serialize};
/// All commands that can mutate a Model.
///
/// Serialized as `{"op": "<variant>", ...rest}` where `rest` contains
/// the variant's fields flattened into the same JSON object.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum Command {
/// Add a category (dimension).
AddCategory { name: String },
/// Add an item to a category.
AddItem { category: String, item: String },
/// Add an item inside a named group.
AddItemInGroup { category: String, item: String, group: String },
/// Set a cell value. `coords` is a list of `[category, item]` pairs.
SetCell {
coords: Vec<[String; 2]>,
#[serde(flatten)]
value: CellValueArg,
},
/// Clear a cell.
ClearCell { coords: Vec<[String; 2]> },
/// Add or replace a formula.
/// `raw` is the full formula string, e.g. "Profit = Revenue - Cost".
/// `target_category` names the category that owns the formula target.
AddFormula { raw: String, target_category: String },
/// Remove a formula by its target name.
RemoveFormula { target: String },
/// Create a new view.
CreateView { name: String },
/// Delete a view.
DeleteView { name: String },
/// Switch the active view.
SwitchView { name: String },
/// Set the axis of a category in the active view.
/// axis: "row" | "column" | "page"
SetAxis { category: String, axis: String },
/// Set the page-axis selection for a category.
SetPageSelection { category: String, item: String },
/// Toggle collapse of a group in the active view.
ToggleGroup { category: String, group: String },
/// Save the model to a file path.
Save { path: String },
/// Load a model from a file path (replaces current model).
Load { path: String },
/// Export the active view to CSV.
ExportCsv { path: String },
/// Import a JSON file via the analyzer (non-interactive, uses auto-detected proposals).
ImportJson {
path: String,
model_name: Option<String>,
/// Dot-path to the records array (empty = root)
array_path: Option<String>,
},
}
/// Inline value for SetCell
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CellValueArg {
Number { number: f64 },
Text { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl CommandResult {
pub fn ok() -> Self {
Self { ok: true, message: None }
}
pub fn ok_msg(msg: impl Into<String>) -> Self {
Self { ok: true, message: Some(msg.into()) }
}
pub fn err(msg: impl Into<String>) -> Self {
Self { ok: false, message: Some(msg.into()) }
}
}