Source: context/code/cli.md
On this page
- The headless CLI
- 1 — the status line is the only channel, so it must be printed
- 2 — stderr, all of it, and why no split was possible
- 3 — the exit code (superseded by §10)
- 4 — a run that changed nothing does not write
- 5 — the refusal names the word the user typed
- 6 — the help's ex-commands were the unguarded surface
- 7 — a script's command is built against the cursor it will run against
- 8 — the interactive arm is parse, then the cursor; it is never a second parser
- 9 — the extent is fixed, because it reaches the document
- 10 — the exit code asks whether the change happened, never whether a chain stopped
- 11 — a headless import refuses colliding records unless told otherwise (improvise-cgqt)
- 12 — import takes folders, and the CLI only says which arguments are folders (improvise-lpzu)
- 13 — a headless import refuses rather than saving less than was asked (improvise-8jk6.4, 8jk6.5, 8jk6.6)
- 14 — the command language reads |pipe quoting|, and a surplus argument is refused (improvise-31oe.3)
- 15 — a command that names something the document lacks is refused (improvise-31oe.2)
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 (superseded by §10)
This section used to say: exit 1 on a parse error or on App::last_batch_aborted(), and a refusal that only writes a status exits 0, because closing that needed a severity beside status_msg — a ViewState change, and so a wire change (gotcha 25). Both halves were wrong in a way worth keeping. The abort half made a success exit 1 (improvise-c5ip), and the severity half named the wrong home: the question an exit code asks is about one batch, not about a message, so it belongs beside batch_aborted on App and never reaches the wire. §10 is what replaced it.
#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 (an ex-command only, since improvise-wk18: built by ex!) 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 the cursor; 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. The interactive arm is now built from parse, not beside it:
parse(args) -> C— the only function that reads arguments a caller supplied.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.- For
register_at_cursoronly, the registry itself then sets the command's cursor — itsCursorState, reached throughAtCursor::cursor_mut— to the live one. That is the whole of what the interactive arm adds to a parsed command, and no registration supplies it.
Three consequences hold by construction rather than by test. Whatever parse accepts, the interactive arm accepts — nothing after parse can fail — so the §7 gate cannot pass a segment the loop then refuses. 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. And no field parse set can be overwritten from context, because nothing a registration writes can name one: the cursor slot is typed &mut CursorState, and no command parses a CursorState out of its arguments. register_pure and register_nullary already had one arm; they are register with no fallback.
That last one is improvise-yf7p, and it is why the registry lost a parameter. #171 took a third function, contextualize(C, &CmdContext) -> C, handed the built command and not the args, and stated honestly that it could still overwrite a parsed field — naming the field was "a visible line in review". Review then injected five such overwrites on commands other than the one with a test (yank, paste and drill-into-cell taking their key from the cursor over the parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor clamping its parsed delta to ±1) and the whole workspace stayed green. A hole guarded by review discipline plus one command's test is not guarded. Every contextualizer in the tree fell into one of two shapes, and each had an answer that removes the function rather than checking it:
- The cursor (
Move,EnterAdvance,CommitAndAdvance). Always oneCursorStatefield, alwaysCursorState::from_ctx. So the registry does it, through a slot whose type is the guard: anAtCursorimpl pointing at aCellKeyor a delta is a compile error, which was tried. - A read of context that belonged in
execute(toggle-panel-visibility'scurrently_open,cycle-panel-focus's three flags,move-panel-cursor's cursor and bound). These copied context into the command only to read it back;executenow readsctxdirectly, the same answertoggle-panel-and-focusgot below. Those three areregister/register_nullarynow, so their interactive arm is exactlyparse.
What execute does with a parsed field is still the command's own logic and is tested as such — toggle_panel_visibility_reads_the_panel_it_names and move_panel_cursor_moves_from_the_named_panels_own_cursor fail if the panel or the delta is ignored there. The difference is that the registry is no longer a second place such a bug can be written.
What is still representable, stated rather than hidden:
- 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. - A new command could declare a second
CursorStatefield and parse it from args;cursor_mutcould then point at the wrong one. No command has one, and a parsed cursor would be a new idea deserving its own argument.
toggle-panel-and-focus did not fit #171's contextualize either, 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.
#9 — the extent is fixed, because it reaches the document
A headless run used to read crossterm::terminal::size(), and the page size derived from it decides where page-scroll lands and therefore which record the next cursor-reading command writes (improvise-up8g). So one script wrote a different document in a different window, and with no tty — CI, cron — the read failed and left App's default, so CI and a shell disagreed too. It was invisible until §7 let a script see its cursor.
HEADLESS_EXTENT in main.rs pins it to 80x24 — the size CI already had, so nothing that passed there moves. There is no flag to change it, for §2's reason: a second knob with nothing asking for it. A script that wants a particular row says so with move-selection or jump-*, which do not depend on any window.
What this does not close: the TUI still takes its extent from the terminal, and that is correct there, because the window really is an input to an interactive session. The ticket's wider claim — that a replayable log needs the extent in it (improvise-ilvl) — is about that log, which does not exist yet; a headless run has nothing to replay except its script, so pinning is the whole fix for this surface.
#10 — the exit code asks whether the change happened, never whether a chain stopped
App::last_batch_aborted() answered the wrong question for an exit code, in both directions at once.
- A success that exited 1 (improvise-c5ip).
commit-cell-editends inEnterAdvance, which emitsAbortChainwhen the cursor is already bottom-right or the grid is empty: the advance's premise no longer holds. The write had already landed and was saved, and the run exited 1 with nothing on stderr. Before §7 that was every scripted commit. - A refusal that exited 0 (improvise-udt2).
AddIteminto a missing category writes the error and does not abort, because nothing after it depends on the item. So the flag never saw it.
An abort is a statement about the chain; a refusal is a statement about the request. AbortChain stops a chain without refusing anything, and AddItem refuses without stopping anything. The two coincide only for AddCategory and the file writes, which is why reading one for the other looked right for so long.
So App carries a second per-batch outcome, batch_refused, reset where batch_aborted is and written by exactly one method, App::refuse(msg), which sets the status and the flag together — a refusal cannot write its message and forget the flag. Commands reach it through effect::refuse, the Refuse effect, beside effect::set_status for reports. main.rs exits 1 on last_batch_refused() and no longer reads last_batch_aborted() at all.
Why not a severity on status_msg, which udt2 proposed. A severity on the message would live in ViewState, so it would be portable state sent to every client in a Snapshot, and it would go stale: every one of the ~60 writers of status_msg would have to reset it or leave the previous message's severity in place. A per-batch flag cannot go stale, because apply_effects clears it before every batch, and it is not in ViewState, so nothing on the wire carries it or has to agree with it. The TUI colouring the ticket hoped for is still possible later, and would want the severity on the message; that is a separate change with a separate staleness problem.
The classification is per writer. Every refusal in ui/effect.rs, in cmd/{cell,commit,fit,grid,mode,panel,search,text_buffer}.rs and in command/keymap.rs::dispatch goes through refuse. The last of them were converted in two steps:
- improvise-i3o5:
EditOrDrill's collapsed-group arm, and the three spelled-paste declines incell.rs(aggregate, coordinate column, inside a drill). - improvise-cwfj: the three
cmd/panel.rsdeclines, anddispatch's arg-error arm.
The sweeps that pin refusals revert refuse( calls, so they cannot see a refusal written as set_status. Finding one takes a read of every set_status on a decline path, and that is how both batches were found.
A bound key whose command will not build is a refusal (the orchestrator's ruling, improvise-cwfj). The key asked for something that did not happen, which is this section's definition. So dispatch answers a constructor's Err with refuse(e) rather than set_status(e). That arm is where every key-path decline lands, including p, x and i onto an aggregate, a fold, a coordinate column or inside a drill.
Before the conversion, the key path was checked for any reader of the flag:
last_batch_refusedis read in production only byHost::mutating_frame, which handles frames and not keys, and bymain.rs's headless loop, which handles:lines and not keys.RefuseisViewOnly, likeSetStatus, so theDirtya key returns and the projections built from it are unchanged.- The browser lockstep suite compares
ViewState, includingstatus_msg, never the flag, andRefusewrites the same message.
The whole workspace stayed green with the change. q on a dirty document is deliberately left a report: headless, its text is false — the CLI saves anyway (improvise-ep5o). The two rivals udt2 named both stay green: a successful :w and a successful fit-measure report through set_status and exit 0.
remove-formula of a formula that is not there now exits 1 (improvise-tej1, improvise-9trl). It used to exit 0: the effect was keyed by names and removing nothing was a silent no-op. Since tej1, RemoveFormula names its formula by FormulaId, and remove-formula <category> <target> resolves the two names to an id in execute. When nothing matches it refuses with No formula for <target> in <category>, so a script that removes a formula it never added now fails where it used to pass. That is the §10 rule applied: nothing changed, so the request was not met. remove_formula_of_a_missing_formula_is_a_refusal pins it, with a present formula as the control.
#11 — a headless import refuses colliding records unless told otherwise (improvise-cgqt)
import --no-wizard takes --on-duplicate refuse|sum|separate, and the default is refuse. The argument is in context/code/formula-and-io.md, "Colliding records are a question, not a default". In short, a default of sum is the silent outcome the user called critical, and a default of separate reshapes a scripted document. The refusal prints every colliding tuple through WizardCollision::describe, the wizard's own wording, then the flag. It exits non-zero and writes nothing. It is checked before the build rather than read off the build's error, so the listing is structured data and not a parsed sentence. separate uses ImportPipeline::suggested_duplicate_category, and there is deliberately no flag to name the category yet.
#12 — import takes folders, and the CLI only says which arguments are folders (improvise-lpzu)
improvise import takes any mix of files and folders. A folder stands for its *.csv files, in name order. A folder always merges, even when it holds a single CSV, so that file keeps its File item and view. A single file named directly still imports alone (the user's ruling on the PR). The rules and their reasons are in context/code/formula-and-io.md, "A folder of CSVs is one sparse model, with a view per file". The CLI's part is the one question Storage cannot answer: whether a path is a folder. get_import_data asks Path::is_dir for each argument, hands the resulting ImportPaths to expand_import_paths over PhysicalFs, and from there takes the path a list of files always took. A folder with no CSV in it is refused before anything is read. The message names the folder, and the run exits non-zero and writes nothing.
A merge now reaches the build as an ImportPipeline carrying the merged files' sources, rather than as a bare JSON value. Both --no-wizard and the TUI wizard (run_tui → App::start_import) therefore build the per-file views. The headless path matters most, because the code-maat pipeline (improvise-1xfb.5.1) runs there. a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte pins the views, the sparse Data lines and the byte-identical second run, with the first run's output sitting in the folder it reads.
#13 — a headless import refuses rather than saving less than was asked (improvise-8jk6.4, 8jk6.5, 8jk6.6)
The user treats the headless command and model layer as a product of its own, and the scripted import --no-wizard is where it meets a pipeline. The repo-stats pipeline (improvise-1xfb.5.1) found three ways that import saved something other than what the flags asked for, and still exited 0. The reasoning for each is in context/code/formula-and-io.md, "A CSV field is text, and the field's role reads it" and "An import refuses what it cannot keep". The CLI's contract:
--categorykeeps the CSV text. A forced category's items are the file's text, verbatim:00565796stays00565796, and220944e2stays220944e2. Measures still read as numbers. A JSON number has no source text, so it is named as JSON writes it (1e3is1000.0).- An unparseable
--formulais a refusal. It exits non-zero and writes nothing. Every bad formula is named on stderr, each with its parse error. This is §10's rule: the request was not met, so the exit code says so. The wizard refuses the same text at the formula step, with the same wording. - An import that kept no record is a refusal. It exits non-zero, writes nothing, and says why: every record was skipped as not a JSON object, or the source had none.
- An import that skipped some records exits 0 by default. It writes the model and warns with a count. With
--strict(the user's ruling on PR #321) any skipped record is a refusal instead: exit 1, the same count named, and nothing written. The argument is informula-and-io.md.
Two refusals bail in run_headless_import before the build: the --strict skip check (src/main.rs:305) and the --on-duplicate refuse collision check. The rest come from build_model_with_warnings as an Err. All of them reach main as exit 1 before persistence::save. So "writes nothing" holds by statement order, not by where the refusal is raised. The headless tests check it by asserting that the output path does not exist.
#14 — the command language reads |pipe quoting|, and a surplus argument is refused (improvise-31oe.3)
create-view |By area| used to make a view named |By and drop area| without a word. Two separate defects, fixed separately.
The quoting. parse.rs now has one lexer, lex, and everything else is built on it: parse_line_with, segments and parse_segment_interactive. The rules are:
- A
|…|piece is read byimprovise_formula::quote::unescape_pipe_body, the one home of pipe quoting (gotcha 34). So\|,\\and\nmean exactly what they mean in.improvand in formulas. The lexer only finds the closing pipe: a backslash takes the next character with it, so\|does not close. - A piece may sit anywhere in a word, and the pieces and bare text are joined. So a coordinate quotes each half,
|Sales Region|/|East Coast|, the way a.improvdata line quotes each name. The alternative, a pipe only at the start of a word, would have left no way to spell a coordinate with a space in it. "…"is kept, verbatim, with no escapes, and a|inside it stays a|. It is not a workaround to retire. Formula text carries its own pipes:add-formula "Net = |Gross Rev| - Cost"must reachparse_formulawith the pipes on. If the line had pipes only, every pipe in a formula would need escaping. Its old leniency is kept too: a"that is never closed runs to the end of the line. A|that is never closed is refused, naming the text from the open pipe on, because a name is never meant to run to the end.- The separator is a bare
.word. A quoted|.|or"."is an argument, and a.inside|a . b|does not split the line. The oldsplit_on_dotwas a second scanner that knew double quotes and not pipes. With one lexer there is no second scanner to disagree. - A comment line (
#,//) is not lexed, so an unmatched pipe in a comment is not an error.
segments now returns Result, because an unterminated pipe is a fact about the whole line. main.rs refuses the line before running any of it, as §7 does for a parse error, and ExecuteCommand refuses it on the status line.
What is still not spellable: an item whose name contains /. The coordinate is split on the first / after unquoting, so |a/b|/x is still read as category a and item b/x. Closing that needs the lexer to hand structured coordinates to the commands, which is a larger change than this ticket.
The arity. Every registration now declares MaxArgs::AtMost(n) or MaxArgs::Any beside its prototype. CmdRegistry::parse and CmdRegistry::interactive apply it after construction succeeds. So the rule is written once and every command gets it, and a new registration cannot compile without a declaration. The refusal names the command, the limit and every surplus argument (create-view: takes at most 1 argument(s), got 2; unexpected "area"). A headless run exits non-zero through the §7 parse gate, and the : line refuses on the status line.
It runs after the command's own parse, deliberately. A command whose own refusal states its shape keeps it: add-formula's "quote the formula" (improvise-z05h) is more use than an arity, and it is what formula Product Total = Widget + Gadget still says.
MaxArgs::Any is a claim, and the list of commands making it is short: add-items, set-cell, set-format (it joins its words), clear-cell, yank, paste, drill-into-cell and commit-cell-edit. Each takes a list of items or coordinates. no_command_silently_drops_an_argument_it_was_given names that list, so widening another command to Any has to add it there.
#15 — a command that names something the document lacks is refused (improvise-31oe.2)
set-axis Regoin row, set-page Region Nowhere and set-axis Region sideways all exited 0 and said nothing. §10's rule applies: nothing changed, so the request was not met.
- An unreadable axis is an argument error.
set-axisnow checks its axis withparse_axiswhen it is parsed, so a script is refused before any of it runs.executestill refuses an axis it cannot read rather than returning nothing. That arm cannot be reached through the registry. - An unknown category, item, group or view is a refusal, from
RequireKnown(below), because only the document knows what it holds.effect::unknown_nameasks the active view (try_axis_of) for a category. That is exactly the condition under whichView::set_axiswas a silent no-op. It askseffective_item_namesfor an item, so a formula target is a known_Measureitem. It asks both an item's own group and the category'sgroupsfor a group.add_item_in_groupregisters noGroup, and a parent group has no items, so either one alone misses a real group. A view is asked ofworkbook.views.
The siblings fixed with it: hide-item and show-item (hiding an unknown item wrote it into hidden_items), toggle-group, and switch-view (the effect ignores a view it lacks, and says nothing). set-page with an unknown item was worse than a no-op: it wrote the name into page_selections.
The check runs at apply time, as an effect of its own. execute runs against one context built before the line ran, and a : line builds every segment up front, so a check in execute refused create-view Second . switch-view Second and add-item Type Tools . set-page Type Tools after their first halves had landed, while improvise cmd (which rebuilds the context per segment) accepted them. Each of these commands therefore returns RequireKnown(needs) followed by the real effect. RequireKnown refuses and aborts the batch (gotcha 21), so the real effect never applies. It is a ViewOnly effect that writes nothing and is off the wire on purpose: the wire's own effects (model_effects, gotcha 38) stay non-refusing, since a refusing effect there becomes a frame rejection (MutationRefused). That is arguably right, but it changes the protocol, and a browser composes these only from names it was sent. It is left for a ticket of its own.
A refused name stops the rest of a : line, because the abort ends the one batch the whole line runs as. Headless runs each segment as its own batch, so an abort never crosses a . there: :set-page Type Nowhere . add-item Type X adds nothing, while improvise cmd on the same line exits 1 and still adds X. load/Save already behaved this way. The two front ends' chain semantics are left for a ticket of their own.
The interactive cursor fallbacks of clear-cell, yank, paste and drill-into-cell answer only for coordinates that were OMITTED (only_when_omitted). Coordinates that were given and are malformed are refused, never replaced by the cursor's cell.