Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions docs/superpowers/plans/2026-07-05-fm-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# FM-REPEAT Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** `C-x z` repeats the last command (press `z` to repeat more), correctly — via dispatch-context capture in `mgwrap` and re-running through `mgwrap`.

**Architecture:** `mgwrap` captures `last_command` + `last_f`/`last_n` + a `last_key` snapshot for each real command; `repeat` restores `key` and re-invokes through `mgwrap` (so `rptcount`/undo, ABORT, prefix args, and `selfinsert`'s char all work).

**Tech Stack:** core C (`kbd.c`, `keymap.c`, `funmap.c`, `def.h`); pty test harness.

## Global Constraints

- Plain C, both builds (macOS, Alpine/musl, `c-legacy` under `-Wall -Wextra -Werror`).
- `struct key` is `{int k_count; KCHAR k_chars[MAXKEY];}` (`src/key.h`) — a fixed struct, plain-assignable.
- Keymap elements stay sorted ascending by `k_num` (`doscan`).
- Verified Emacs 30.2: `C-x z` = `repeat`; the repeat char is the last key (`z`).

---

### Task 1: `C-x z` repeat

**Files:** Modify `src/kbd.c` (globals + `mgwrap` + `repeat`), `src/def.h` (prototype), `src/keymap.c` (`cXmap` `z`), `src/funmap.c` (entry). Test: `tests/test_editor.cpp`.

**Interfaces:** Produces `int repeat(int, int)`.

- [ ] **Step 1: Failing tests**
```cpp
TEST_CASE("C-x z repeats self-insert with the right char (not 'z')")
{
auto dir = make_temp_dir();
std::ofstream(dir / "t.txt") << "\n";
const std::string p = (dir / "t.txt").string();
winsize ws{}; ws.ws_row = 24; ws.ws_col = 80;
int master = -1;
pid_t pid = ::forkpty(&master, nullptr, nullptr, &ws);
REQUIRE(pid >= 0);
if (pid == 0) { ::setenv("TERM", "xterm", 1);
::execl(NEOMG_BINARY, "neomg", p.c_str(), (char *)nullptr); _exit(127); }
std::string content;
if (wait_for(master, "*scratch*", std::chrono::seconds(8)) ||
wait_for(master, "t.txt", std::chrono::seconds(2))) {
(void)!::write(master, "x", 1); // self-insert 'x'
(void)!::write(master, "\x18z", 2); // C-x z -> repeat self-insert -> 'x'
(void)!::write(master, "z", 1); // z -> another 'x'
(void)!::write(master, "\x18\x13", 2);
(void)wait_for(master, "Wrote", std::chrono::seconds(8));
}
quit_neomg(master, pid);
std::ifstream in(p); std::stringstream ss; ss << in.rdbuf();
content = ss.str();
fs::remove_all(dir);
CHECK(content.find("xxx") != std::string::npos); // not "xzz"/"xz"
CHECK(content.find('z') == std::string::npos);
}

TEST_CASE("C-x z with no prior command is refused")
{
auto dir = make_temp_dir();
std::ofstream(dir / "t.txt") << "hello\n";
const std::string p = (dir / "t.txt").string();
winsize ws{}; ws.ws_row = 24; ws.ws_col = 80;
int master = -1;
pid_t pid = ::forkpty(&master, nullptr, nullptr, &ws);
REQUIRE(pid >= 0);
if (pid == 0) { ::setenv("TERM", "xterm", 1);
::execl(NEOMG_BINARY, "neomg", p.c_str(), (char *)nullptr); _exit(127); }
bool refused = false;
if (wait_for(master, "hello", std::chrono::seconds(8))) {
(void)!::write(master, "\x18z", 2); // C-x z, nothing run yet
refused = wait_for(master, "No last command to repeat",
std::chrono::seconds(8));
}
quit_neomg(master, pid);
fs::remove_all(dir);
CHECK(refused);
}

TEST_CASE("C-x z repeats movement, then C-x z after undo continues undoing")
{
auto dir = make_temp_dir();
std::ofstream(dir / "t.txt") << "a\nb\nc\nd\ne\n";
const std::string p = (dir / "t.txt").string();
winsize ws{}; ws.ws_row = 24; ws.ws_col = 80;
int master = -1;
pid_t pid = ::forkpty(&master, nullptr, nullptr, &ws);
REQUIRE(pid >= 0);
if (pid == 0) { ::setenv("TERM", "xterm", 1);
::execl(NEOMG_BINARY, "neomg", p.c_str(), (char *)nullptr); _exit(127); }
std::string content;
if (wait_for(master, "a", std::chrono::seconds(8))) {
(void)!::write(master, "\x01", 1); // C-a (line 1)
(void)!::write(master, "\x0e", 1); // C-n -> line 2
(void)!::write(master, "\x18z", 2); // C-x z repeat C-n -> line 3
(void)!::write(master, "\x0b", 1); // C-k kill line 3 ("c")
(void)!::write(master, "\x18\x13", 2);
(void)wait_for(master, "Wrote", std::chrono::seconds(8));
}
quit_neomg(master, pid);
std::ifstream in(p); std::stringstream ss; ss << in.rdbuf();
content = ss.str();
fs::remove_all(dir);
CHECK(content.find("c") == std::string::npos); // C-n repeated -> landed on line 3
CHECK(content.find("b") != std::string::npos);
}
```

- [ ] **Step 2: Run → FAIL** (`C-x z` unbound; `xxx` not present, no "No last command" message).
`cmake --build build --target test_editor && ./build/tests/test_editor --test-case="*C-x z*"`

- [ ] **Step 3: Globals + `mgwrap` + `repeat` (`src/kbd.c`).** Replace `mgwrap` (which has a `static PF ofp`) with module globals and the new command:
```c
PF last_command; /* last real command (for repeat) */
static int last_f, last_n;
static struct key last_key;

int
mgwrap(PF funct, int f, int n)
{
if (funct != rescan &&
funct != negative_argument &&
funct != digit_argument &&
funct != universal_argument &&
funct != repeat) {
if (funct == last_command)
rptcount++;
else
rptcount = 0;
last_command = funct;
last_f = f;
last_n = n;
last_key = key; /* snapshot the invoking key sequence */
}

return ((*funct)(f, n));
}

/*
* C-x z: repeat the last command, re-running it through mgwrap with its
* captured key sequence and numeric arg so state-dependent commands
* (selfinsert, undo, ...) behave. Press z again to keep repeating.
*/
int
repeat(int f, int n)
{
int s, c;

if (last_command == NULL) {
dobeep();
ewprintf("No last command to repeat");
return (FALSE);
}
for (;;) {
key = last_key; /* restore the sequence the command saw */
s = mgwrap(last_command, last_f, last_n);
if (s != TRUE) /* ABORT or FALSE -> stop */
return (s);
update(CMODE);
c = getkey(FALSE);
if (c != 'z')
break;
}
ungetkey(c);
return (TRUE);
}
```
(`mgwrap`'s signature is unchanged; it is already declared. `key`/`getkey`/`ungetkey`/`update`/`CMODE`/`rptcount` are all in scope in `kbd.c`.)

- [ ] **Step 4: Prototype (`src/def.h`)** beside the other command prototypes:
```c
int repeat(int, int);
```

- [ ] **Step 5: Bind `C-x z` (`src/keymap.c`).** Add before `cXmap`:
```c
static PF cXz[] = {
repeat /* z */
};
```
In `cXmap`, change `KEYMAPE (6)` → `(7)` and `6,\n\t6,` → `7,\n\t7,`, and add a 7th element after `{ '^', 'u', cXcar, NULL }` (ascending order preserved):
```c
{
'^', 'u', cXcar, NULL
},
{
'z', 'z', cXz, NULL
}
```

- [ ] **Step 6: Funmap (`src/funmap.c`)**:
```c
{repeat, "repeat", 1, NULL},
```

- [ ] **Step 7: Run → PASS.** `cmake --build build && ctest --test-dir build --output-on-failure`. Confirm neomg still starts (the earlier keymap edit crash class): launch it, see the mode line.

- [ ] **Step 8: Commit**
```bash
git add src/kbd.c src/def.h src/keymap.c src/funmap.c tests/test_editor.cpp
git commit -m "feat(repeat): C-x z repeats the last command (context-preserving)"
```

---

## Final verification (before PR)

- [ ] macOS: `cmake --build build && ctest --test-dir build --output-on-failure`.
- [ ] Alpine/musl: `docker build -f docker/Dockerfile.alpine -t mg-repeat .`.
- [ ] `c-legacy`: `cmake --build --preset c-legacy` (0 warnings under `-Werror`); launch `neomg` and confirm it starts.
- [ ] TSan (editor): `cmake --build build-tsan && ./build-tsan/tests/test_editor --test-case="*C-x z*"`.
- [ ] Manual smoke: `x` `C-x z` `z z` → `xxxx`; `C-n C-x z z`; `C-u 4 C-n` then `C-x z`; several edits then `C-x u` then `C-x z z` keeps undoing; `C-x z` at startup beeps; `M-g`/`C-l`/`C-h k` still work (regression on the shared keymap/dispatch).
- [ ] Update `todo.md`: mark FM-REPEAT done.
- [ ] Open the PR based on `neomg`.
101 changes: 101 additions & 0 deletions docs/superpowers/specs/2026-07-05-fm-repeat-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# FM-REPEAT — `C-x z` repeat-last-command (done right)

## Why

Emacs `C-x z` is `repeat`: re-run the last command, and keep pressing `z` to
repeat more (verified on 30.2). neomg has no such command. A first attempt (in
the FM-QUICK-KEYBINDS branch) re-invoked the last function pointer **directly**,
bypassing the `mgwrap`/`doin` dispatch — which silently corrupted data:
`selfinsert` reads its char from the global `key.k_chars` (still holding the
`C-x z` keystroke → typing `x` then `C-x z` inserted `xz`), and `undo` keys off
`rptcount` (only `mgwrap` maintains it) so `C-x z` after `C-x u` reversed the
undo. That attempt was dropped. This spec does it correctly: capture the
dispatch context and re-run *through* `mgwrap`.

## Design

### Context capture in `mgwrap` (`src/kbd.c`)

`mgwrap(PF funct, int f, int n)` is the single command-dispatch point. It
already tracks the last function (a `static PF ofp`) for `rptcount`. Widen that
into module globals and capture the full context — for every real command
(keeping the existing exclusions of `rescan`/`negative_argument`/
`digit_argument`/`universal_argument`, and adding `repeat` so it never records
itself):
- `PF last_command` (replaces the `static ofp`),
- `int last_f`, `int last_n` — the numeric-prefix args,
- `struct key last_key` — a snapshot of the global `key` (`{k_count,
k_chars[MAXKEY]}`, a fixed struct → plain assignment), i.e. the key sequence
that invoked the command.

The existing `rptcount` bookkeeping (`funct == last_command ? rptcount++ :
rptcount = 0`) is preserved with `last_command` in place of `ofp`.

### `repeat(int f, int n)` (`src/kbd.c`)

```
if (last_command == NULL) -> dobeep + "No last command to repeat" + FALSE
loop:
key = last_key; // restore invoking key sequence
s = mgwrap(last_command, last_f, last_n);// re-run THROUGH mgwrap
if (s != TRUE) return (s); // ABORT or FALSE -> stop
update(CMODE);
c = getkey(FALSE);
if (c != 'z') break; // Emacs: press the repeat char to repeat
ungetkey(c); // non-z key resumes normal dispatch
return (TRUE);
```

Why this is correct:
- **selfinsert**: reads `key.k_chars[k_count-1]`; restoring `key` yields the
original char, not the `z` (`x` → `xx`).
- **undo**: routing through `mgwrap` runs the `rptcount` bookkeeping, so a
repeated `undo` sees `rptcount > 0` and *continues* the chain (Emacs behavior)
instead of resetting to the head (which reversed it).
- **ABORT**: `gotoline` and other commands return `ABORT` (2) on a cancelled
prompt; the `!= TRUE` check stops the loop rather than treating it as success.
- **numeric prefix**: `last_f`/`last_n` are reused, so `C-u 5 C-n` then `C-x z`
moves 5 lines (the repeat's own `f`/`n` are intentionally ignored).
- **no recursion**: `repeat` is excluded from being stored as `last_command`, so
`mgwrap(last_command, …)` can never dispatch `repeat`.

### Binding

- `src/keymap.c`: `cXmap` grows `KEYMAPE (6)` → `(7)` with a sorted `'z'`
element (`cXz[] = { repeat }`) after the `'^'..'u'` element.
- `src/funmap.c`: `{repeat, "repeat", 1, NULL}`.
- `src/def.h`: `int repeat(int, int);` prototype.

All plain C, both builds.

## Edge cases

- `last_command` is zero-initialized (BSS) → NULL until the first real command;
`repeat` guards it.
- A repeated command that reads its own input from the minibuffer (e.g. a search)
prompts each iteration — acceptable, matches re-running it.
- If `getkey` returns a non-`z` key, it is `ungetkey`'d so normal dispatch runs
it (no key lost or duplicated).

## Testing (pty)

- **selfinsert**: type `x`, `C-x z` → buffer has `xx`; another `z` → `xxx`
(the original bug produced `xz`).
- **undo continuation**: make several separate edits, `C-x u` (undo one), then
`C-x z` → undoes the *next older* edit (not a redo of the undo). Assert the
buffer keeps shrinking toward the original, and does not grow back.
- **movement + prefix**: `C-n` then `C-x z` → point moved two lines total (kill
the landing line and assert which line emptied); and `C-u 3 C-n` then `C-x z`
→ moved 3 + 3.
- **no prior command**: fresh session, `C-x z` → "No last command to repeat"
(beep), buffer unchanged.

Verify macOS + Alpine/musl + `c-legacy` (all plain C, ships in both).

## Out of scope (todo.md)

- `C-u N C-x z` (repeat N times) and `repeat-mode`.
- `repeat` inside keyboard-macro replay (`executemacro` bypasses `mgwrap`, so
`last_command` reflects pre-replay state — pre-existing architectural trait).
- Repeating by pressing the *last key of the repeated command* (Emacs also
allows this); only the `z` repeat char is supported.
1 change: 1 addition & 0 deletions src/def.h
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ int bsmap(int, int);
void ungetkey(int);
int getkey(int);
int doin(void);
int repeat(int, int);
int rescan(int, int);
int universal_argument(int, int);
int digit_argument(int, int);
Expand Down
1 change: 1 addition & 0 deletions src/funmap.c
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ static struct funmap functnames[] = {
{filewrite, "write-file", 1, NULL},
{yank, "yank", 1, NULL},
{yank_pop, "yank-pop", 1, NULL},
{repeat, "repeat", 1, NULL},
{NULL, NULL, 0, NULL}
};

Expand Down
Loading
Loading