Files
improvise/README.md
T
Edward Langley 188f3e94ef ci(pages): build the wasm bundle and publish web/ to GitHub Pages
improvise-d31's finished artifact depends on improvise-9x6 (the worker-hosted
server) through improvise-djm. The *pipeline* does not, and building it now is
what stops the delivery half from being a mystery when the worker lands: the
swap from websocket transport to worker transport then changes what is
bundled, not how it is built and shipped.

Two jobs in ci.yml rather than a second workflow. `pages-build` runs on every
push and pull request, so a change that breaks `web/build.sh` or the bundle
fails on the PR that made it. `pages-deploy` runs only on a push to main and
`needs: [test, wasm, pages-build]`, so nothing that failed `cargo test` or the
existing wasm32 gate can reach the published site — the deploy depends on
those checks instead of repeating them.

Deliberately not path-filtered. The bundle is improvise-wasm-client plus
improvise-protocol, improvise-core and improvise-formula, so a filter meaning
"skip when the bundle cannot have changed" would have to name most of the
workspace, and getting it wrong leaves a stale site with nothing saying so.

wasm-pack is a pinned release tarball, v0.15.0 — the version `nix develop`
now supplies — so the local run that produced 346 KB of wasm (120 KB gzipped)
is evidence about the CI run. The devShell was missing wasm-pack entirely:
the `wasm` job only runs `cargo check`, which does not need it, so nothing had
ever required the one tool that turns a passing check into a loadable page.

What this cannot do yet, said on the page itself rather than routed around:
web/app.js is a thin client that holds no model, so a copy served from a
static host paints an empty grid until it is pointed at an improvise-ws-server
with ?ws=. The notice is static HTML, not something app.js decides, because it
is true on every load and a page that cannot reach a server cannot be trusted
to explain why. README says the same and names the deployed URL.

Verified: cargo test --workspace 1134 passed / 0 failed, clippy clean, fmt
clean, actionlint clean, ./web/build.sh succeeds and the assemble step
produces a 388 KB site whose every reference resolves inside it. NOT verified:
the workflow has never run — GitHub Pages is not enabled on the repository
(GET /repos/fiddlerwoaroof/improvise/pages is 404), which is improvise-3gy.
The deploy job asks configure-pages to enable it; if the token may not, that
step is where the run fails and the fix is the repository setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 18:50:15 -07:00

7.3 KiB

improvise

Terminal pivot-table modeling in the spirit of Lotus Improv — multidimensional cells, formulas over dimensions instead of cell addresses, and vim-style keybindings for reassigning axes on the fly.

demo

Why this exists

Lotus Improv (NeXT, 1991) separated data from its presentation: cells were addressed by named dimensions, not grid coordinates, and rearranging a view didn't break formulas. That idea never made it into mainstream tools. Excel pivot tables borrowed the visual rearrangement but kept cell-address formulas. Terminal data tools like sc-im and VisiData do different things well — sc-im is a traditional spreadsheet, VisiData is a data explorer — but neither offers the dimension-keyed data model that made Improv interesting. improvise is a small attempt to bring that model to a modern terminal, with a formula language that references category and item names, views that can be rearranged with a single keystroke, and a plain-text file format you can diff in git.

See docs/design-notes.md for the original product vision and non-goals.

Quick start

nix build .
./result/bin/improvise examples/demo.improv

Or import your own CSV:

./result/bin/improvise import path/to/data.csv

The included examples/demo.improv was generated from examples/demo.csv:

improvise import examples/demo.csv \
  --no-wizard \
  --category Region --category Product --category Customer \
  --measure Revenue --measure Cost \
  --time Date --extract Date:Month \
  --axis Region:row --axis Product:row \
  --axis Date_Month:column --axis Measure:column \
  --axis Customer:page --axis Date:none \
  --formula "Profit = Revenue - Cost" \
  --name "Acme Sales Demo" \
  -o examples/demo.improv

Key bindings to try first

Key Action
T Enter tile mode — reassign category axes
[ / ] Cycle through page-axis items
> Drill into an aggregated cell
< Return from drill-down
F Open the formula panel
t Transpose rows and columns
? / F1 Full key reference
:w Save
:q Quit

Installation

With Nix (preferred)

nix build .
# or install into your profile:
nix profile install .

From crates.io

cargo install improvise

Prebuilt binaries

See the GitHub releases page for prebuilt binaries (Linux x86_64, macOS Intel and Apple Silicon).

The data model

Every cell lives at the intersection of named categories and items — (Region=East, Measure=Revenue) — not at a grid address like B3. A model can have up to 12 categories, each with an ordered list of items that can be organized into collapsible groups (e.g. months grouped into quarters).

Formulas reference dimension names, not cell addresses:

Profit = Revenue - Cost
Tax = Revenue * 0.08
Margin = IF(Revenue > 0, Profit / Revenue, 0)
BigDeal = Sum(Revenue WHERE Region = "West")

The formula language supports + - * / ^, comparisons, IF, and aggregation functions (Sum, Avg, Min, Max, Count) with optional WHERE clauses. Formulas apply uniformly across all intersections — no copying, no dragging, no $A$1 anchoring.

Views and axes

A view assigns each category to one of four axes: row, column, page (slicer), or hidden. The grid layout is a pure function of (Model, View) → GridLayout — transposing is just swapping row and column assignments, and it happens in one keystroke (t). Page-axis categories act as filters: [ and ] cycle through items.

Press T to enter tile mode, where each category appears as a tile in the tile bar. Move between tiles with h/l, then press Space to cycle axes or r/c/p to set one directly.

Records mode (R) flips to a long-format view by assigning the virtual _Index and _Dim categories to row and column axes. Drill-down (>) on an aggregated cell captures a snapshot; edits accumulate in a staging area and commit atomically on exit.

File format

Models persist to a plain-text .improv format that reads like markdown and diffs cleanly in git:

# Sales 2025

## Category: Region
- North
- South
- East [Coastal]
- West [Coastal]
> Coastal

## Category: Measure
- Revenue
- Cost
- Profit

## Formulas
- Profit = Revenue - Cost [Measure]

## Data
Region=East, Measure=Revenue = 1200
Region=East, Measure=Cost = 800

## View: Default (active)
Region: row
Measure: column
format: ,.2f

Gzip-compressed .improv.gz is also supported. Legacy JSON is auto-detected for backward compatibility.

Import and scripting

The import wizard analyzes CSV columns and proposes each as a category (string-valued), measure (numeric), time dimension (date-like, with optional year/month/quarter extraction), or skip. Multiple CSVs merge automatically with a synthetic "File" category.

All model mutations go through a typed command registry, so the same operations that back the TUI work headless:

# single commands
improvise cmd 'add-cat Region' 'add-item Region East' -f model.improv

# script file (one command per line, # comments)
improvise script setup.txt -f model.improv

In the browser

The browser page is at https://fiddlerwoaroof.github.io/improvise/, built and published from main by the pages-build / pages-deploy jobs in .github/workflows/ci.yml. A deploy only happens after cargo test and the wasm32 check have passed.

It needs a server, and a static host is not one. The page is a thin client: it holds no model, and every question about a cell — its text, whether it may be typed into — is answered by a server and arrives already decided. So the published page paints an empty grid until it is pointed at one:

cargo run -p improvise-ws-server -- examples/demo.improv --port 8787

then open the page with ?ws=ws://127.0.0.1:8787. Over https a ws:// address is blocked as mixed content, so for now a local server is most easily paired with a local page:

./web/build.sh                              # wasm-pack -> web/pkg (gitignored)
python3 -m http.server 8080 --directory web

Running with no separate process is the in-page worker, improvise-9x6, which is not built yet. Until it lands, what is deployed is the delivery pipeline, not a working demo.

What's interesting about the architecture

All user actions flow through a two-phase command/effect pipeline. Commands are pure functions: they receive an immutable CmdContext (model, layout, cursor position, mode) and return a list of effects. Effects are the only things that mutate app state, and each one is a small, debuggable struct. This means commands are testable without a terminal, effects can be logged or replayed, and the 40+ commands and 50+ effect types are all polymorphic — dispatched through trait objects and a registry, not a central match block. The keybinding system gives each of the 14 modes its own keymap, with Emacs-style prefix keys for multi-stroke sequences.

Expectations

improvise is a personal project I built for my own use. I'm sharing it because other people might find it useful, but I can't promise active maintenance or feature development. Issues and PRs are welcome but may not get a fast response. If you want to build on it, fork away.

License

Apache-2.0