Edward Langley 243b9a9b6b fix(command): the record tier does not hoist to the frame
The independent review of PR #26 found a **P1 regression 90ea26e
introduced**, confirmed by a probe against a real `Host` rather than by
reading. Hoisting the basis check to the frame hoisted *all three tiers*, so
every mutation after the first was admitted with no record check at all:

    A composes [SetCell(North), SetCell(South)] against an honest basis
    B writes South = 99
    A's frame arrives  ->  accepted, South = 20, B's write gone, silently

At the base commit the second command was checked and the frame refused. This
is precisely the lost update ADR 001's record tier exists to prevent, and it
is invisible to the other two tiers because `SetCell` is `CellData` and moves
neither of them.

**It cannot be deferred into the clause-4 bead**, and that is the part worth
being sure of: clause 4 never fires on this frame, because *both writes
succeed*. Rollback is about partial application; this is a wrong answer,
fully applied.

**The per-command check was doing two jobs and the commit noticed one.** "Is
the client's picture of the *document* current" is a frame-level fact and
hoists correctly. "Is the client's picture of *this record* current" does
not: which records are at stake is a property of the command, which is why
`records_named` is an exhaustive match over `ModelCommand` (gotcha 43). So
every mutation now answers for its own records, against **the frame's**
basis — the one its first mutation stated, not each command's own field, so a
later command cannot claim a fresher picture than the frame was composed
under.

**Except the records the frame is creating**, or clause 3 is lost again. The
argument that motivated clause 3 is true of records a chain **creates** and
was over-generalised to records it merely **writes** — and those are the ones
the tier is for. The exemption is therefore stated over a *property*, not a
list of commands: a record that **neither resolves in the document nor is
claimed by the basis** is one the frame is about to create
(`will_be_created_by_the_frame`). Every other combination still answers,
including "does not resolve but *is* claimed", which is a client believing in
something that has been removed and stays `RecordGone`. It is asked only
inside a frame — a first command has no predecessor that could have created
anything, so a lone write to a missing record is refused as before.
`stale_record` itself is untouched: one home for what the four combinations
mean, and the frame path differs only in *which* records it asks about.

Three arms, because a fix that refuses everything passes the first alone and
a fix that checks nothing passes the other two. Two mutants injected:
dropping the per-command record check fails
`a_later_write_in_a_chain_still_answers_for_its_own_record`; dropping the
create exemption fails `a_data_dependent_chain_lands_in_order`.

**The fixture is the finding underneath the finding.** `reduce::caught_up`
builds the claim for *one command*, so a frame's records beyond the first are
simply absent from it and come back `RecordUnstated` — my first probe failed
for that reason and not the one it was testing. A real client calls
`BasisCache::claim` once with every record its frame names, which the new
`frame_basis` test helper reproduces.

Also from the review:

- **`a_frame_whose_later_mutation_fails_is_not_yet_undone` did not pin the
  hole it is named after** — the repo's signature defect sitting on the one
  test whose stated job is to fail loudly if anyone believes the hole is
  closed. It asserted North was removed and nothing was rejected, both true
  of any *wholly successful* frame; the reviewer retargeted the second
  command to a live record and it still passed. The missing assertion is the
  one that names the hole: **the second mutation did not land.** Verified by
  making the frame wholly successful, which now fails it.

- **Three surviving statements of the removed rule**, gotcha 48's grep rule
  again: `basis.rs` (whose "the other TWO classes" over "TWO classes live
  here" gave it away), `host.rs`, and `browser_round_trip.rs` — whose live
  one-mutation assertion is kept but **re-justified as a characterization of
  what `keys::commit` emits**, not a protocol constraint, so a future reader
  does not take it for one.

- `ApplyAndClearDrill`'s `dirty = true` is gone: it declares `Model(CellData)`
  and `apply_effects` raises the flag off that. Its tests drove `apply`
  directly and so measured the effect in isolation rather than the path
  production takes; routed through `apply_effects`, one of them reveals that
  **a drill exited with no edits marks the document dirty** — the same
  over-approximation a failed `LoadModel` gets, true since the flag moved and
  invisible until now. Recorded in the assertion.

- `a_loaded_document_is_dirty_because_its_path_still_names_the_old_one` pins
  the `LoadModel` behaviour change and *why* it is right: `LoadModel` does not
  move `file_path`, so the in-memory document really is a different one from
  the file the path names. It gates `autosave_if_needed` and the `:q`
  refusal, so it is worth a test.

- `ui/reduce.rs`'s module doc pointed hosts at `Sessions::apply`, which has
  had no production caller since `Host::commands` began answering the whole
  frame. Now points at `apply_checked`.

Merged `main` (a15cc73). One semantic conflict git merged cleanly: PR #25
added `effect::MarkDirty` in `cmd/fit.rs`, which no longer exists — dropped,
since `AddFormula` beside it is a `Model` effect.

1291 passing; clippy, fmt and the wasm gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:08:02 -07:00
2026-04-26 11:08:41 -07:00
2026-04-02 16:21:45 -07:00
2026-04-11 00:07:59 -07:00
2026-09-19 17:52:43 -07:00
2026-04-26 09:45:23 -07:00
2026-04-11 00:15:42 -07:00
2026-09-19 13:07:42 -07:00
2026-04-11 00:07:57 -07:00
2026-04-26 11:10:32 -07:00

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 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, and it is the case worth optimising for: http://localhost is not a secure-context problem, so nothing below is needed to develop, to demo locally, or to share a TUI-hosted document with another tab on the same machine.

One caveat even there, for the reason spelled out below: a WebSocket is exempt from the same-origin policy, and nothing in the server checks Origin — so a page you did not open can connect to ws://127.0.0.1:8787 and read or edit the document while the server runs.

How much the browser mitigates that is in flux and should not be leaned on. Chromium has been rolling out Local Network Access, which puts requests from a public-network origin to a loopback or private-network address behind a permission prompt; per Chrome's own documentation WebSockets were not yet covered when the prompt began shipping, with coverage planned, so whether a given browser and version gates this socket is not something this project can state — and there is no browser here to test it in. Two gaps are structural rather than version-dependent: it is a Chromium feature, so other browsers need not behave the same; and it only engages for a public origin, so the locally-served page this section is about is outside it entirely.

Treat it, then, as tolerable for a local session you start and stop, and as a reason not to leave one running unattended. The durable fix is the server checking Origin itself, which it does not yet do.

Sharing a TUI-hosted document with a remote browser

Reaching improvise-ws-server from the deployed https page means terminating TLS in front of it. improvise-ws-server speaks plain ws:// only — there is no rustls, no native-tls, nothing in the crate or its dependencies — so wss:// is not obtainable from the binary itself, and this is the supported way to get it.

Say the cost first, because it is real. This means the documented path to sharing involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary. It is the answer anyway, for a reason given under "Why not native wss://" below. If you only want a document in a browser, you do not need any of this — that is the standalone worker deployment, it is what the Pages site is for, and it needs no server process at all.

Read this before following any of it. improvise-ws-server has no authentication of any kind, and that is a deliberate MVP limit rather than an oversight — its own module doc says so. Anyone who can open a socket to it can rewrite every document it serves. Loopback binding is the only thing currently standing between that and the network, and terminating TLS in front of it removes exactly that protection: the proxy is public, so the endpoint is public, and --bind 127.0.0.1 protects the port while doing nothing about the proxy. TLS gets you confidentiality, not access control.

And the control has to be one a browser can actually satisfy, which rules out most of the obvious ones. The page opens the socket as new WebSocket(url) — one argument, a URL — and the distinction that decides everything here is ambient credentials versus challenge–response:

  • The handshake is an HTTP request, so the browser attaches what it attaches automatically — cookies for that host above all. That is why an identity-aware proxy can work at all.
  • What the page cannot do is set a header (there is no Authorization argument to new WebSocket) or answer a prompt (a handshake surfaces no login dialog, and its failure reaches script as a bare connection error).

So HTTP basic auth is not a locked door here, it is a 401 the page can neither answer nor explain — the same shape as the self-signed certificate that rules out native wss:// below. Anything challenge-shaped fails for that reason; anything ambient may work. Client-certificate mTLS sits awkwardly between the two — the certificate is offered at the TLS layer rather than by the page, but the selection UI browsers show is a top-level-navigation affordance, and whether a cross-origin socket handshake gets it is not something verified here.

And "who may connect" is two questions, not one. A WebSocket is exempt from the same-origin policy — there is no CORS preflight on a handshake — so any hostile page an allowed user visits can open a socket to your endpoint from that user's own allowed address, and read and write the document. An address check cannot see that, because by then the request genuinely does come from an allowed client. Only the Origin header distinguishes them, and improvise-ws-server never looks at it — it accepts every handshake. So a proxy needs an Origin allow-list in addition to, never instead of, whichever control below you pick. The worked example carries both.

So the honest controls are the ones that need no credential from the page:

  • An IP allow-list in the proxy — the cheapest, and the worked example below. It is about where the client is, so the page sends nothing. On its own it answers only the first of the two questions above.
  • A private network (Tailscale, WireGuard). The peer is only reachable by machines already on it; again the page sends nothing.
  • An identity-aware tunnel (cloudflared with Cloudflare Access) — the ambient case above, and the reason that distinction is drawn at all. It works by a session cookie set after an interactive login on the proxy's own hostname, which the handshake then carries by itself; so it needs the user to visit that hostname first, and it is subject to the browser's third-party-cookie rules. Plausible and not verified here — there is no browser in this repository — so it is named, not written out as a recipe.

What would make this genuinely easy is a token in the URL, because the URL is the one place the page can put a credential deliberately — everything else it sends is ambient. The server has no such capability today, and whether it should grow one — or whether "auth is the proxy's job" becomes a stated design position — is improvise-kpnr.

Until then: share only documents you would be willing to let anyone on the other side of that control edit, and take the tunnel down when you are done.

A worked example with Caddy, which obtains and renews the certificate itself. Point a DNS name at the host, then:

# Caddyfile
improvise.example.com {
    # NOT optional — see above. Without it the document is world-writable.
    # An allow-list rather than a password, because the page cannot answer a
    # challenge on a socket handshake: this is checked against the address.
    @denied not remote_ip 203.0.113.4 198.51.100.0/24
    abort @denied

    # ALSO not optional, and it is NOT redundant with the address check.
    # A WebSocket is exempt from the same-origin policy, so any hostile page
    # an allowed user visits can open this socket FROM THAT ALLOWED ADDRESS
    # and read and edit the document. The address check cannot see that; only
    # the Origin can. Name the exact origins the page is served from.
    @badorigin not header Origin https://fiddlerwoaroof.github.io
    abort @badorigin

    reverse_proxy 127.0.0.1:8787
}
improvise-ws-server bank-info.improv --port 8787   # still plain ws, still loopback
caddy run                                          # terminates TLS, proxies to it

On the same terms as the browser caveat below: this configuration has not been executed in this repository — there is no Caddy and no browser here. It is the form Caddy documents for remote_ip, and the reasoning about what a browser can satisfy is checked against web/app.js, which opens new WebSocket(url) and can therefore send no header. Verify the deny actually denies before trusting it with a document.

Then, from https://fiddlerwoaroof.github.io/improvise/, reload with ?ws=improvise.example.com. Caddy preserves the request path, so the multi-document form works too: serve several files and name one with ?ws=improvise.example.com/bank-info.

Caddy needs no websocket-specific configuration — reverse_proxy passes the Upgrade handshake through. nginx does: it needs proxy_http_version 1.1 and the Upgrade/Connection headers set explicitly, or the handshake is answered as an ordinary request and the socket never opens.

For a one-off share with no DNS name and no certificate of your own, cloudflared tunnel --url http://127.0.0.1:8787 or ngrok http 8787 each print an https hostname that works the same way — pass the hostname to ?ws= without a scheme. Both print a public URL and neither authenticates anything by default, and an unguessable URL is not a control.

Two things carry over from above. ngrok http --basic-auth does not help here — the page cannot answer a basic-auth challenge on a socket handshake — and what you want instead is the same address check the Caddy example uses. But no command line for it is given here on purpose: ngrok has moved IP restriction from the (now deprecated) --cidr-allow flag to a Traffic Policy restrict-ips action passed with --traffic-policy-file, and cloudflared's equivalent is a product feature of its own. Those spellings have already changed once, none of them has been run in this repository, and a recipe that is correct today and silently wrong at the next major version is worse than a pointer — so: consult their current documentation, and confirm the restriction denies before you trust it with a document. If you cannot put an address check in front of the tunnel, do not expose it.

Note the server binds 127.0.0.1 by default and --bind is the only way to widen that. Behind a proxy on the same host you should not widen it: the proxy reaches loopback, and leaving the default keeps the unencrypted port off the network. That is worth doing and is not access control — the proxy is still the open door, which is why the access controls above belong there. Those are access controls and not authentication, which the server still has none of: they answer which clients and which origins may connect, never who is connecting. The distinction is the whole of improvise-kpnr.

Why not native wss://

Declined deliberately rather than merely unimplemented (improvise-o8fa). Adding rustls is the easy half; the certificate is the hard one, and it is what makes the feature worse than the proxy rather than better. A self-signed certificate makes the browser refuse the socket outright — a websocket handshake has no interstitial to click through, so the failure arrives as a bare connection error with no usable indication that a certificate was the reason. That leaves a real certificate or an explicitly trusted local CA (mkcert) as the only pleasant options, and both of those are a second piece of software too. So the choice is not "one binary versus two", it is "two either way, and one of the options additionally ships a TLS stack and a certificate-loading surface in a binary whose job is a pivot table". If sharing a TUI-hosted document to a remote browser ever becomes a common case rather than an occasional one, this is worth revisiting on that evidence.

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

S
Description
No description provided
Readme Apache-2.0 90 MiB
Languages
Rust 77.5%
Prolog 6.4%
Nix 6.1%
JavaScript 4.1%
Shell 3.5%
Other 2.3%