Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
13 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).AddItemwritesworkbook.add_item'sErrstraight 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.rssets 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.
That atomicity claim was false as written (improvise-2bac), and §8 is what
makes it true. The gate runs the pure arm; the loop runs the interactive arm. A
gate is only a gate if every argument list the pure arm accepts, the
interactive arm accepts too — and commit-cell-edit broke it:
add-category Foo . commit-cell-edit editing 5 Foo/x on an empty document
passed the gate, applied the first segment, and then refused the second with
no cell at cursor. Half the line landed.
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).
8 — the interactive arm is parse, then context; it is never a second parser
#151 fixed three instances of a registry with two construction arms demotes
whichever caller reaches the weaker one, one patch each. A post-merge review
found the fourth (improvise-2bac): commit-cell-edit <mode> <value> <coords>,
whose interactive arm read only <mode>, took the value from the edit buffer
and the key from the cursor. So a script that wrote 5 to Region/East
deleted the cell under the cursor — the empty buffer, committed — with exit
0 and nothing on stderr. The fifth was already in the tree:
toggle-panel-and-focus <panel> false false ignored both flags and toggled.
The shape rather than the instances is what changed. CmdRegistry::register
used to take two free functions, parse(args) and interactive(args, ctx),
and nothing related them; each command re-derived its arguments twice and every
disagreement was silent. It now takes three pieces, and the interactive arm is
built from parse, not beside it:
parse(args) -> C— the only function that reads arguments a caller supplied.contextualize(C, &CmdContext) -> C— fills the fields that are context and never argument (aMove's origin cursor, aMovePanelCursor's bound). It is handed the built command and not the args, so it cannot discard them.fallback: Option<(args, ctx) -> C>— reached only whenparserefused. This is where "no coordinates means the cursor" lives:clear-cellbare, andcommit-cell-edit <mode>bare, which is what the keymap binds.
Two consequences hold by construction rather than by test. Whatever parse
accepts, the interactive arm accepts — contextualize is infallible — so the
§7 gate cannot pass a segment the loop then refuses. And an accepted argument
list cannot be overridden by the cursor, because the only function that sees
both args and context never runs on an accepted list. register_pure and
register_nullary already had one arm; they are the contextualize = identity, fallback = None case.
What is still representable, stated rather than hidden:
contextualizecan overwrite a fieldparseset. It would have to name the field, which is a visible line in review, where the old|_args, ctx|was an invisible omission.commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursorfails ifcommit-cell-edit's does.- A
fallbacksees the argsparserefused and could ignore some of them.commit-cell-edit's refuses anything but a lone mode, so the malformedcommit-cell-edit editing 5is an error on the:line — which has no pure gate — rather than a commit of the edit buffer to the cursor cell. - The gate is stricter than the loop wherever a fallback exists: a bare
clear-cellis refused in a script. That is the safe direction — the whole line is refused — and it is unchanged from before #151.
toggle-panel-and-focus did not fit contextualize, because its pure arm
defaulted the omitted flags to true and a contextualizer cannot tell an
omitted flag from an explicit one. So the omission is now in the type —
target: Option<(bool, bool)>, None meaning toggle — and the toggle is
resolved in execute against the same context. That is the honest home: the
decision depends on context, and execute is where context is.
Guards: every_argument_list_the_gate_accepts_the_run_accepts_too enumerates
every registration against every candidate argument list, in a context whose
cursor sits on no cell — the context in which the old commit-cell-edit arm
refused. It lists no commands, so a new registration is covered the day it
lands. It is backed by the process-level tests in tests/headless_cli.rs.