WIP improvise-2bac: interactive arm is parse-then-contextualize

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 20:50:48 -07:00
parent 40b8913397
commit afba3fa0a6
4 changed files with 238 additions and 207 deletions
@@ -139,9 +139,12 @@ pub trait Cmd: Debug + Send + Sync {
/// Factory that constructs a Cmd from text arguments (headless/script).
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
/// Factory constructing a Cmd from the interactive context. It takes both the keymap
/// args and the context, so a command can combine text args with runtime state.
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
/// Fills the context-only fields of a command `parse` already built. It never
/// sees the args, so it cannot discard them — `context/code/cli.md` §8.
pub type ContextualizeFn<C> = fn(C, &CmdContext) -> C;
/// Builds a command from context, reached ONLY for args `parse` refused.
pub type FallbackFn<C> = fn(&[String], &CmdContext) -> Result<C, String>;
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
@@ -183,13 +186,26 @@ impl CmdRegistry {
name
}
/// Register a command with both a text parser and an interactive constructor.
/// The name is derived from a prototype command instance.
pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) {
/// Register a command whose interactive arm is `parse` then `contextualize`, so
/// args `parse` accepts are honoured whole — `context/code/cli.md` §8.
pub fn register<C: Cmd + 'static>(
&mut self,
prototype: &C,
parse: fn(&[String]) -> Result<C, String>,
contextualize: ContextualizeFn<C>,
fallback: Option<FallbackFn<C>>,
) {
self.entries.push(CmdEntry {
name: prototype.name(),
parse: Box::new(parse),
interactive: Box::new(interactive),
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
interactive: Box::new(move |args, ctx| {
let built = match (parse(args), fallback) {
(Ok(cmd), _) => Ok(contextualize(cmd, ctx)),
(Err(_), Some(fallback)) => fallback(args, ctx),
(Err(err), None) => Err(err),
};
built.map(|c| Box::new(c) as Box<dyn Cmd>)
}),
});
}
@@ -20,8 +20,7 @@ mod tests {
let ctx = make_ctx(&m, &layout, &reg);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
open: true,
focused: true,
target: Some((true, true)),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
@@ -40,8 +39,7 @@ mod tests {
let ctx = make_ctx(&m, &layout, &reg);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
open: false,
focused: false,
target: Some((false, false)),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
@@ -531,20 +529,24 @@ mod tests {
#[derive(Debug)]
pub struct TogglePanelAndFocus {
pub panel: Panel,
pub open: bool,
pub focused: bool,
/// Explicit `(open, focused)`; `None` toggles: close if open and focused, else open and focus.
pub target: Option<(bool, bool)>,
}
impl Cmd for TogglePanelAndFocus {
fn name(&self) -> &'static str {
"toggle-panel-and-focus"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let (open, focused) = self.target.unwrap_or_else(|| {
let shown = ctx.panel_open(self.panel) && ctx.mode.panel() == Some(self.panel);
(!shown, !shown)
});
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
effects.push(Box::new(effect::SetPanelOpen {
panel: self.panel,
open: self.open,
open,
}));
if self.focused {
if focused {
effects.push(effect::change_mode(self.panel.mode()));
} else {
effects.push(effect::change_mode(AppMode::Normal));
@@ -22,15 +22,50 @@ use super::text_buffer::*;
use super::tile::*;
use super::wizard::*;
/// Explicit coordinates win; the cursor is the fallback — `context/code/cli.md`.
fn key_from_args_or_cursor(args: &[String], ctx: &CmdContext) -> Result<CellKey, String> {
if args.is_empty() {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
} else {
Ok(parse_cell_key_from_args(args))
/// The cell under the cursor: what a coordinate command falls back to when given none.
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
}
/// A `Move` built from args takes the live cursor as its origin.
fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move {
Move {
cursor: CursorState::from_ctx(ctx),
..cmd
}
}
/// A commit built from args still advances from the live cursor.
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
CommitAndAdvance {
cursor: CursorState::from_ctx(ctx),
..cmd
}
}
/// `<mode>` alone: commit the edit buffer to the cell under the cursor.
fn commit_at_cursor(
args: &[String],
ctx: &CmdContext,
advance: AdvanceDir,
) -> Result<CommitAndAdvance, String> {
let [mode] = args else {
return Err("takes a mode alone, or a mode, a value and coordinates".into());
};
Ok(CommitAndAdvance {
edit_mode: parse_mode_name(mode)?,
value: read_buffer(ctx, "edit"),
key: cursor_key(ctx)?,
advance,
cursor: CursorState::from_ctx(ctx),
})
}
/// For a command with no context-only fields: `parse`'s result is used as built.
fn as_parsed<C>(cmd: C, _ctx: &CmdContext) -> C {
cmd
}
/// Build the default command registry with all commands.
/// Registry names MUST match the `Cmd::name()` return value.
pub fn default_registry() -> CmdRegistry {
@@ -51,14 +86,12 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("clear-cell requires at least one Cat/Item coordinate".into());
}
Ok(Box::new(ClearCellCommand {
Ok(ClearCellCommand {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(ClearCellCommand { key }))
})
},
as_parsed,
Some(|_, ctx| Ok(ClearCellCommand { key: cursor_key(ctx)? })),
);
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
r.register_pure(&FitMeasure::prototype(), FitMeasure::parse);
@@ -80,16 +113,8 @@ pub fn default_registry() -> CmdRegistry {
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
r.register(
&HelpPageNextCmd(vec![]),
HelpPageNextCmd::parse,
|_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))),
);
r.register(
&HelpPagePrevCmd(vec![]),
HelpPagePrevCmd::parse,
|_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))),
);
r.register_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse);
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
// ── Navigation (unified Move) ──────────────────────────────────────
r.register(
@@ -102,22 +127,14 @@ pub fn default_registry() -> CmdRegistry {
require_args(args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
Ok(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::default(),
cmd_name: "move-selection",
}))
},
|args, ctx| {
require_args(args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::from_ctx(ctx),
cmd_name: "move-selection",
}))
})
},
move_from_cursor,
None,
);
// Jump-to-edge commands: first/last row/col
macro_rules! reg_jump {
@@ -133,7 +150,7 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: $name,
},
|_| {
Ok(Box::new(Move {
Ok(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
@@ -141,19 +158,10 @@ pub fn default_registry() -> CmdRegistry {
},
cursor: CursorState::default(),
cmd_name: $name,
}))
},
|_, ctx| {
Ok(Box::new(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::from_ctx(ctx),
cmd_name: $name,
}))
})
},
move_from_cursor,
None,
);
};
}
@@ -170,21 +178,14 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
Ok(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::default(),
cmd_name: "scroll-rows",
}))
},
|args, ctx| {
require_args(args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::from_ctx(ctx),
cmd_name: "scroll-rows",
}))
})
},
move_from_cursor,
None,
);
r.register(
&Move {
@@ -195,28 +196,21 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
Ok(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::default(),
cmd_name: "page-scroll",
}))
},
|args, ctx| {
require_args(args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::from_ctx(ctx),
cmd_name: "page-scroll",
}))
})
},
move_from_cursor,
None,
);
r.register(
&EnterAdvance {
cursor: CursorState::default(),
},
|_| {
Ok(Box::new(EnterAdvance {
Ok(EnterAdvance {
cursor: CursorState {
row: 0,
col: 0,
@@ -227,13 +221,12 @@ pub fn default_registry() -> CmdRegistry {
visible_rows: 20,
visible_cols: 8,
},
}))
})
},
|_, ctx| {
Ok(Box::new(EnterAdvance {
cursor: CursorState::from_ctx(ctx),
}))
|_, ctx| EnterAdvance {
cursor: CursorState::from_ctx(ctx),
},
None,
);
// ── Cell operations ──────────────────────────────────────────────────
@@ -245,14 +238,12 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("yank requires at least one Cat/Item coordinate".into());
}
Ok(Box::new(YankCell {
Ok(YankCell {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(YankCell { key }))
})
},
as_parsed,
Some(|_, ctx| Ok(YankCell { key: cursor_key(ctx)? })),
);
r.register(
&PasteCell {
@@ -262,14 +253,12 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into());
}
Ok(Box::new(PasteCell {
Ok(PasteCell {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(PasteCell { key }))
})
},
as_parsed,
Some(|_, ctx| Ok(PasteCell { key: cursor_key(ctx)? })),
);
// clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
@@ -313,21 +302,15 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("drill-into-cell requires Cat/Item coordinates".into());
}
Ok(Box::new(DrillIntoCell {
Ok(DrillIntoCell {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(DrillIntoCell { key }))
})
},
as_parsed,
Some(|_, ctx| Ok(DrillIntoCell { key: cursor_key(ctx)? })),
);
r.register_nullary(|| Box::new(ViewNavigate { forward: false }));
r.register(
&ViewNavigate { forward: true },
|_| Ok(Box::new(ViewNavigate { forward: true })),
|_, _| Ok(Box::new(ViewNavigate { forward: true })),
);
r.register_nullary(|| Box::new(ViewNavigate { forward: true }));
r.register_pure(&EnterMode(AppMode::Normal), |args| {
require_args(args, 1)?;
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
@@ -345,38 +328,20 @@ pub fn default_registry() -> CmdRegistry {
r.register(
&TogglePanelAndFocus {
panel: Panel::Formula,
open: true,
focused: true,
target: None,
},
|args| {
// Parse: toggle-panel-and-focus <panel> [open] [focused]
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
Ok(Box::new(TogglePanelAndFocus {
panel,
open,
focused,
}))
},
|args, ctx| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
// Default interactive: if already open+focused -> close, else open+focus
let currently_open = ctx.panel_open(panel);
let currently_focused = ctx.mode.panel() == Some(panel);
let (open, focused) = if currently_open && currently_focused {
(false, false) // close
} else {
(true, true) // open + focus
};
Ok(Box::new(TogglePanelAndFocus {
panel,
open,
focused,
}))
let target = args.get(1).map(|s| {
let open = s == "true";
(open, args.get(2).map(|s| s == "true").unwrap_or(open))
});
Ok(TogglePanelAndFocus { panel, target })
},
as_parsed,
None,
);
r.register(
&TogglePanelVisibility {
@@ -386,20 +351,16 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
Ok(Box::new(TogglePanelVisibility {
Ok(TogglePanelVisibility {
panel,
currently_open: false,
}))
})
},
|args, ctx| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
let currently_open = ctx.panel_open(panel);
Ok(Box::new(TogglePanelVisibility {
panel,
currently_open,
}))
|cmd, ctx| TogglePanelVisibility {
currently_open: ctx.panel_open(cmd.panel),
..cmd
},
None,
);
r.register(
&CyclePanelFocus {
@@ -408,19 +369,18 @@ pub fn default_registry() -> CmdRegistry {
view_open: false,
},
|_| {
Ok(Box::new(CyclePanelFocus {
Ok(CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
}))
})
},
|_, ctx| {
Ok(Box::new(CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
}))
|_, ctx| CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
},
None,
);
r.register(
&MovePanelCursor {
@@ -433,29 +393,26 @@ pub fn default_registry() -> CmdRegistry {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MovePanelCursor {
Ok(MovePanelCursor {
panel,
delta,
current: 0,
max: 0,
}))
})
},
|args, ctx| {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
let (current, max) = match panel {
|cmd, ctx| {
let (current, max) = match cmd.panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
Ok(Box::new(MovePanelCursor {
panel,
delta,
MovePanelCursor {
current,
max,
}))
..cmd
}
},
None,
);
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
r.register_nullary(|| Box::new(AddRecordRow));
@@ -490,11 +447,7 @@ pub fn default_registry() -> CmdRegistry {
// ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true }));
r.register(
&ToggleGroupAtCursor { is_row: false },
|_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
);
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: false }));
r.register_nullary(|| Box::new(HideSelectedRowItem));
// ── Text buffer ──────────────────────────────────────────────────────
@@ -537,27 +490,16 @@ pub fn default_registry() -> CmdRegistry {
return Err("commit-cell-edit requires a mode, value, and coords".into());
}
let edit_mode = parse_mode_name(&args[0])?;
Ok(Box::new(CommitAndAdvance {
Ok(CommitAndAdvance {
key: parse_cell_key_from_args(&args[2..]),
value: args[1].clone(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode,
}))
},
|args, ctx| {
require_args(args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
key,
value,
advance: AdvanceDir::Down,
cursor: CursorState::from_ctx(ctx),
edit_mode,
}))
})
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
);
r.register(
&CommitAndAdvance {
@@ -568,19 +510,8 @@ pub fn default_registry() -> CmdRegistry {
edit_mode: AppMode::editing(),
},
|_| Err("commit-and-advance-right requires context".into()),
|args, ctx| {
require_args(args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
key,
value,
advance: AdvanceDir::Right,
cursor: CursorState::from_ctx(ctx),
edit_mode,
}))
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
);
r.register(
&CommitBuffer {
@@ -589,13 +520,14 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
Ok(Box::new(CommitBuffer { kind }))
Ok(CommitBuffer { kind })
},
|args, ctx| {
as_parsed,
Some(|args, ctx| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], ctx.mode)?;
Ok(Box::new(CommitBuffer { kind }))
},
Ok(CommitBuffer { kind })
}),
);
r.register_nullary(|| Box::new(CommitExport));
r.register_nullary(|| Box::new(CommitSave));
@@ -880,6 +812,7 @@ mod panel_open_tests {
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: false"),
@@ -897,6 +830,7 @@ mod panel_open_tests {
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: true"),
+79
View File
@@ -357,3 +357,82 @@ fn an_explicit_coordinate_still_beats_the_cursor() {
"the named cell survived: {left:?}"
);
}
/// North=1, South=2, East=3 on the row axis; the cursor starts on North.
fn three_cells(scratch: &Scratch, name: &str) -> PathBuf {
let doc = three_rows(scratch, name);
run_cmd(
&[
"set-cell 1 Region/North",
"set-cell 2 Region/South",
"set-cell 3 Region/East",
],
&doc,
);
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
doc
}
fn value_of(doc: &Path, item: &str) -> Option<String> {
cell_lines(doc)
.into_iter()
.find(|l| l.contains(&format!("Region={item}")))
.and_then(|l| l.rsplit(" = ").next().map(str::to_string))
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn commit_cell_edit_writes_the_named_cell_and_spares_the_cursors() {
let scratch = Scratch::new("commit-explicit");
let doc = three_cells(&scratch, "commit.improv");
let out = run_cmd(
&["move-selection 1 0 . commit-cell-edit editing 5 Region/East"],
&doc,
);
assert!(out.status.success(), "the line failed: {}", stderr_of(&out));
let cells = cell_lines(&doc);
assert_eq!(value_of(&doc, "East").as_deref(), Some("5"), "{cells:?}");
assert_eq!(
value_of(&doc, "South").as_deref(),
Some("2"),
"the cell under the cursor was touched: {cells:?}"
);
assert_eq!(
value_of(&doc, "North").as_deref(),
Some("1"),
"the origin cell was touched: {cells:?}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_line_whose_gate_passes_runs_whole() {
let scratch = Scratch::new("commit-atomic");
let doc = scratch.path("atomic.improv");
let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5 Foo/x"], &doc);
assert!(
!stderr_of(&out).contains("Parse error"),
"a line the gate accepted was refused part-way: {}",
stderr_of(&out)
);
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(
text.lines().any(|l| l.contains("Foo=x") && l.ends_with("= 5")),
"the commit did not land: {text}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_malformed_commit_line_is_refused_whole() {
let scratch = Scratch::new("commit-malformed");
let doc = seeded(&scratch, "malformed.improv");
let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5"], &doc);
assert!(!out.status.success(), "a malformed line exited 0");
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(
!text.contains("Foo"),
"the first half of a refused line landed: {text}"
);
}