Edward Langley d120596a10 docs: the supported way to reach a TUI-hosted document over https
Per the user's ruling on improvise-o8fa: document the reverse-proxy path
properly, implement no TLS.

The existing text already named the symptom correctly -- an https origin
refuses `ws://` as mixed content, `improvise-ws-server` has no TLS, so
`wss://` is unobtainable from it -- but it gestured at "a reverse proxy or a
tunnel" and stopped, which is not something a reader can follow. So README
gains a worked Caddy example (Caddyfile, both commands, and the `?ws=` value
to reload with), the nginx difference that actually bites (`proxy_http_version
1.1` plus the Upgrade/Connection headers, without which the handshake is
answered as an ordinary request), the cloudflared/ngrok one-liners, and the
note that `--bind` should be left at loopback behind a proxy on the same host.

It also says the cost rather than papering it: the documented path to sharing
involves a second piece of software, which is a poor answer for a tool whose
selling point is being one binary. And it says what that cost buys against --
the standalone worker deployment is the point of the Pages site and needs no
server at all, and `http://localhost` pairing needs none of this either.

Native `wss://` is recorded as DECLINED WITH REASON rather than left as a gap.
The substance is the certificate, not rustls: a self-signed cert makes the
browser refuse the socket with no usable error, because a websocket handshake
has no interstitial to click through. A real cert or mkcert are the only
pleasant options and both are a second piece of software too -- so the choice
is not one binary versus two, it is two either way, with one option
additionally shipping a TLS stack in a pivot-table binary.

web/index.html is the second home for the same fact and is updated to point at
the worked example rather than restating it (gotcha 48: a fact stated twice is
a grep). Both were checked for text the correction left stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:38:28 -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.

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.

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

# Caddyfile
improvise.example.com {
    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

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.

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 means the unencrypted port is not reachable from anywhere else.

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%