Files
improvise/context/code/cli.md
T
Edward Langley 64bcb821da fix(cli): a script's command is built against the cursor it will run against (improvise-6uva)
`improvise cmd` and `improvise script` resolved every command through
`CmdRegistry::parse` — the pure arm, which builds `CursorState::default()`: row
0, col 0, and a grid of zero rows and zero columns. The interactive arm, which
builds `CursorState::from_ctx(ctx)`, was never reached from a script. So every
`Move` and `enter-advance` snapshotted a zeroed cursor at PARSE time, clamped to
(0, 0) and emitted `SetSelected(0, 0)` — worse than a no-op, since it RESETS the
cursor. Nothing could position it, so every cursor-reading command was pinned to
the origin.

Reproduced at a9cad15 on a document with Region on row carrying North, South,
East: `move-selection 2 0 . hide-selected-row-item` and a bare
`hide-selected-row-item` both wrote `hidden: Region/North`. After the fix the
first writes `hidden: Region/East`.

The repair is `parse::segments` + `parse::parse_segment_interactive`, with the
headless loop re-parsing per segment, because command n must see command n-1's
cursor. Line atomicity is preserved by keeping the existing whole-line
`parse_line` as a syntax gate, so a line that parsed before still parses and the
only behavioural change is which cursor the commands see.

Two things the ticket did not predict. The TUI's `:` line had the identical
defect — `ExecuteCommand` also used the pure arm — and is fixed the same way.
And doing that surfaced a second instance of the same shape one level down:
`register_pure` built an interactive arm that refused EMPTY args while its own
pure arm accepted them, so `w` with no path parsed one way and refused the
other, breaking `improvise cmd 'w'` and `:w` the moment either re-parsed
interactively. Both arms are now `parse`.

Evidence: context/test-evidence/cli.md; reasoning: context/code/cli.md section 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 18:59:08 -07:00

8.5 KiB

The headless CLI

improvise cmd and improvise script are one function — run_headless_commands in src/main.rs; run_headless_script reads the file and delegates. Every claim below therefore holds for both, and a fix to one is a fix to both by construction rather than by remembering.

1 — the status line is the only channel, so it must be printed

App has no return channel for "what happened". A command's outcome — a refusal, a warning, a fit-measure's reproducing seed — is written to ViewState::status_msg and rendered by the TUI's bottom bar. Headless, nothing read it, so success and refusal were byte-identical: silence and exit 0 (improvise-q914, improvise-j6x8). Two things followed that are worth keeping in mind, because both were mistaken for correct behaviour by agents working above this layer:

  • A P1's carefully-worded refusal can be written, tested at the effect, and still reach nobody, because the test asserts the effect's status and the surface that would show it drops it. That is this repo's characteristic defect one layer out, and the only cure is a test at the process boundary — tests/headless_cli.rs, which spawns the real binary.
  • improvise cmd 'fit-measure …' looked like it did nothing whether it worked or not, which made the whole GP feature read as broken from a script.

The loop takes the message with std::mem::take and clears it per command, so one command's status cannot be reported twice or attributed to its successor.

2 — stderr, all of it, and why no split was possible

The tempting design is stdout for a report and stderr for a refusal. It cannot be built honestly, because status_msg is a bare String with no severity, and both candidate classifiers are disproved by code in the tree:

  • "A refusal is a batch that aborted." Only three effects set abort_effects (gotcha 21). AddItem writes workbook.add_item's Err straight to the status and does not abort — and that is q914's own worked example of a refusal. So this classifier puts a refusal on stdout labelled a success.
  • "A refusal is anything that spoke." cmd/fit.rs sets a status on success, carrying the seed that reproduces the fit. So this one labels a deliverable a failure.

Guessing between them would be precisely the adjacent-check defect context/design-principles.md §7 names. So everything goes to stderr: it is the diagnostics stream, it leaves stdout free for a future data output, and it matches every other diagnostic main.rs already emits (Saved to …, warning: …, Parse error: …).

No --quiet flag. Silence is never the default and 2>/dev/null is the standard suppression; a flag would be a second knob with nothing asking for it.

3 — the exit code, and the hole left open deliberately

Exit 1 on a parse error (pre-existing) or on App::last_batch_aborted(). Both are real flags read off state the code already keeps, so neither is a guess.

What this does not cover, stated rather than papered over: a refusal that only writes a status still exits 0. Closing it needs a severity beside status_msg, which lives in ViewState and is therefore a wire change (gotcha 25) reaching the browser client — out of proportion to the CLI, and filed as improvise-udt2, which names both rivals so the next attempt cannot rediscover them. Until then the README says plainly that exit 0 means "nothing aborted", not "everything worked", because a contract that over-promises is worse than a narrow one.

4 — a run that changed nothing does not write

persistence::save used to run unconditionally, so improvise cmd normalised the document on every invocation — 1e20 expanded to twenty digits. The cost was not cosmetic: an agent editing a fixture by string-replacing a value after one run got a silent no-op, and two misleading test results out of it.

The gate is changed, accumulated from model_state.dirty after each command, not read once at the end. Reading it at the end is wrong because effect::Save clears the flag, so [add-item …, w other.improv] would look unchanged and drop the edit from the -f document. !path.exists() is or-ed in so that naming a new file still creates it.

Over-approximation is the safe direction here, the same one Dirty::of takes (gotcha 35): a Model(_) effect that happens to write the value already there still costs one redundant save.

5 — the refusal names the word the user typed

CmdRegistry::resolve maps formula → add-formula before the arity check runs, so every validator that spelled its own name reported a command nobody typed (improvise-z05h). The fix moves the name to the one place that knows which of the two the user wrote: require_args no longer takes a word, and CmdRegistry::parse prefixes "{typed}: " onto any Err.

That removed 41 name literals from cmd/, which is the same argument gotcha 15 makes about registry.rs having no name literals — a name spelled twice can be spelled wrong once. interactive deliberately does not prefix: dispatch in command/keymap.rs already formats "{name}: {e}" with the bound name (improvise-55l5, PR #126), and prefixing in both would double it.

The message states the shape rather than the arity, because "requires 1-2 argument(s), got 6" does not tell anyone to quote anything.

6 — the help's ex-commands were the unguarded surface

help.rs derives HelpLine::Keys rows from the keymap and drops any row whose command does not resolve, so a key row cannot go stale. HelpLine::Literal and HelpLine::Accent rows are hand-written prose and had nothing checking them — which is how all three documented spellings of :formula came to be ones the parser refuses.

every_ex_command_the_help_pages_show_is_one_the_parser_accepts closes that surface rather than the one instance: every help line whose trimmed text opens with : is split on its colons and each piece handed to parse_line. The split exists because one row shows two examples side by side.

What it still cannot see, and should be closed if the welcome text moves again: draw.rs::draw_welcome builds its lines as a local array inside a ratatui render function, so nothing can walk them. Its :formula line was corrected by hand.

7 — a script's command is built against the cursor it will run against

improvise cmd and improvise script used to resolve every command through CmdRegistry::parse — the pure arm, which builds CursorState::default(): row 0, col 0, and a grid of zero rows and zero columns. The interactive arm, which builds CursorState::from_ctx(ctx), was never reached from a script.

So every Move (move-selection, scroll-rows, page-scroll, the four jump-*) and enter-advance snapshotted a zeroed cursor at parse time, clamped to (0, 0), and emitted SetSelected(0, 0). That is worse than a no-op: it resets the cursor. Nothing could position it, so every cursor-reading command — clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, the three delete-*-at-cursor, commit-and-advance — was pinned to the origin (improvise-6uva). It was introduced with CursorState and the two-arm registry in April and predates the browser work by five months.

A registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves. That is the shape to remember, and it has a second instance in the same fix: the register_pure entries had an interactive arm that refused empty args while their pure arm accepted them, so :w (save to the current path) parsed one way and refused the other. Both arms are now parse, which is what "needs no interactive context" was supposed to mean; a_pure_commands_two_arms_agree_on_no_arguments is the guard.

The repair is parse::segments plus parse::parse_segment_interactive, and the loop re-parses per segment, because command n must see command n-1's cursor — which means construction cannot be hoisted out of the loop. Line atomicity is preserved by keeping the existing whole-line parse_line as a syntax gate before anything in the line runs: a line that parsed before still parses, and a line that did not still does not, so the only behavioural change is which cursor the commands see.

The TUI's : line had the same defect and is fixed the same way, with one limitation that is not new: ExecuteCommand returns one effect batch, so the segments of :a . b all see the pre-batch cursor, exactly as a Binding::Sequence does (gotchas 21 and 23).