improvise-vvl3, absorbing improvise-xf1. The condition the user set when
deferring browser testing — "WE HAVE TO HAVE SOMETHING TO TEST" — has been
met: after improvise-9x6, djm, hwpi and the demo model there is a complete
self-contained application whose entire untested surface is what a harness
covers.
`python3 -m http.server` plus one Playwright project, in `harness/`:
harness/opfs-contract/ a wasm crate that runs
`improvise_io::storage::contract::storage_contract`
against `OpfsFs` — the shared function, not a
transcription of it
harness/www/opfs/ the page and the dedicated Web Worker it runs in;
`createSyncAccessHandle` exists nowhere else
harness/tests/ two specs
harness/build.sh site + harness bundle -> harness/serve
It lives at the repo root rather than under `web/`, because `web/` is the
*source of what ships* and every file in it is copied to the site. The
harness serves the **assembled** tree (`web/assemble.sh`, extracted from
the CI workflow in this commit so there is one answer to "how is the site
arranged" rather than two) plus its own `/opfs/` — a harness that runs
against a different arrangement of files is testing something nobody
deploys. `harness/opfs-contract` is deliberately not a workspace member: it
only builds for wasm32, so a member would break `cargo test --workspace`.
What this runs that nothing has ever run:
- `storage_contract` against `OpfsFs`, at `""` **and** at `"docs"`. Two
bugs fell straight out — improvise-vhcz's `list` path shape, whose fix
the `"docs"` run guards (reverting it fails with `lists the one
document`), and the zero-byte entry fix in the previous commit, which
this found by failing on `starts empty`.
- The `keys()` async-iterator walk in `refresh`, hand-rolled through
`js_sys::Reflect` and named by xf1 as the least certain code in the file.
Three entries written out of order, so one passing by accident cannot
carry it.
- `FileSystemReadWriteOptions`' `at` on read and write: a long write then a
short one, which is where an append or a non-zero start shows up.
- `commit_deletes`, both halves — queued but still in OPFS before, gone
after — and **across a reload**, which is the only way to ask whether
OPFS persisted. That reload is also improvise-bck's persistence criterion
at the storage layer.
- improvise-hwpi's three one-assertion shell tests, which that ticket said
it could not write: status after a bad frame, status with no peer, one
repaint on commit. Plus the first-frame watchdog, which was found by
reading and only because someone went looking.
Check the checker, throughout, because a harness that passes against a stub
— or that silently skips because the worker never started — is exactly this
repo's characteristic defect wearing a green tick:
- `run_canary` deliberately fails, through the *same* reporting path every
real check uses, and the spec asserts it is reported as a failure with its
message intact.
- The spec asserts the report contains exactly the expected check names, in
order, so a battery that quietly shrinks is a failure.
- Every shell assertion was injected and watched to fail: removing the
bad-frame catch, the watchdog's `setTimeout`, the socket `error` listener
and the `transport.send(frame)` on commit each fails its own test and
only its own.
- The commit test types `20000` and asserts `20,000`, so a page that echoed
what was typed instead of drawing what the host returned would fail.
One live bug is pinned rather than fixed. `a peer that cannot be reached`
asserts the *history* of `#status`, because improvise-m18f is real and
reproduced here: the socket's `close` listener overwrites the precise
"cannot reach" with "disconnected". That is filed and deliberately not the
same fix as the watchdog's (gotcha 48), so the test asserts both halves and
will speak up whichever one moves.
CI gets a `browser` job that can fail the build, and `pages-deploy` now
needs it. Playwright's Chromium download is ~80 MB and well under a minute
on ubuntu-latest, next to the two wasm-pack builds the job already needs —
so "impractical in this CI" does not arise. A harness nobody runs is worse
than none, because it reads as coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 no server. The page is still a thin client — it holds no model,
and every question about a cell, its text and whether it may be typed into, is
answered by a server and arrives already decided — but the server it talks to
is an in-page Web Worker hosting the whole App over the browser's private
storage (OPFS). It is seeded on first load from web/demo.improv, so a visitor
lands on a document, and edits persist across a reload without leaving the
machine. ?doc= and ?seed= name another document and the file it is first
seeded from.
To run the same page locally:
./web/build.sh # wasm-pack -> web/{pkg,worker-pkg}
python3 -m http.server 8080 --directory web
To open a document hosted by a separate process instead — which is how a
document open in the TUI is shared — start the websocket server and pass
?ws=:
cargo run -p improvise-ws-server -- examples/demo.improv --port 8787
then reload with ?ws=127.0.0.1:8787.
The scheme is not yours to supply and defaults to TLS. ?ws= takes a
host:port; the page uses wss:// when it is itself served over https and
ws:// when it is served over http, because an https origin refuses a ws://
address as mixed content. An explicit ws:// from an https page is upgraded
rather than passed through — passing it through can only produce a failure
that reads like "the server is not running".
So the local pairing above works as written. Reaching a server from the
deployed https page means terminating TLS in front of it (a reverse proxy
or a tunnel): improvise-ws-server speaks plain ws:// only, and native
wss:// is not implemented.
Caveat worth stating plainly: no line of the browser JavaScript has ever
run. There is no browser and no headless DOM in the development environment,
so web/app.js, web/worker.js and the OPFS backend are compile-checked,
syntax-checked and read, never executed.
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
