Three pieces of the standalone deployment, all in web/.
improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.
improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).
One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.
improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.
Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
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.
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
