#321 added its own cli.md section 13, so this PR's sections become 14 (pipe quoting, surplus arguments) and 15 (names the document lacks), with every citation renumbered. The stale descriptions of the deleted unknown_* helpers in cli.md, command.md and the test evidence now describe RequireKnown, and section 15 says a refused name stops the rest of a `:` line but not a headless one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
32 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 (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.