diff --git a/docs/superpowers/plans/2026-07-05-fm-repeat.md b/docs/superpowers/plans/2026-07-05-fm-repeat.md new file mode 100644 index 00000000..90d63d7a --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-fm-repeat.md @@ -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`. diff --git a/docs/superpowers/specs/2026-07-05-fm-repeat-design.md b/docs/superpowers/specs/2026-07-05-fm-repeat-design.md new file mode 100644 index 00000000..7bed1fa0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-fm-repeat-design.md @@ -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. diff --git a/src/def.h b/src/def.h index 81a3b702..8a079d5f 100644 --- a/src/def.h +++ b/src/def.h @@ -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); diff --git a/src/funmap.c b/src/funmap.c index 1b92ba4f..205cd314 100644 --- a/src/funmap.c +++ b/src/funmap.c @@ -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} }; diff --git a/src/kbd.c b/src/kbd.c index 1e5b2b10..b5fcbb87 100644 --- a/src/kbd.c +++ b/src/kbd.c @@ -33,6 +33,9 @@ static int pushedc; struct map_element *ele; struct key key; int rptcount; +PF last_command; /* last real command (for repeat) */ +static int last_f, last_n; +static struct key last_key; /* key sequence that invoked it */ /* * Toggle the value of use_metakey @@ -485,18 +488,60 @@ quote(int f, int n) static int mgwrap(PF funct, int f, int n) { - static PF ofp; - if (funct != rescan && funct != negative_argument && funct != digit_argument && - funct != universal_argument) { - if (funct == ofp) + funct != universal_argument && + funct != repeat) { + if (funct == last_command) rptcount++; else rptcount = 0; - ofp = funct; + 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 (inmacro) { + /* + * During macro replay executemacro() calls each recorded step + * directly (bypassing mgwrap), so last_command would still be + * executemacro -- repeating it would re-run the macro and recurse + * until the stack overflows. Refuse, like query-replace does. + */ + dobeep(); + ewprintf("Can't repeat within a macro"); + return (FALSE); + } + 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); +} diff --git a/src/keymap.c b/src/keymap.c index 634581cf..bd1c1cf0 100644 --- a/src/keymap.c +++ b/src/keymap.c @@ -228,9 +228,13 @@ static PF cXcar[] = { undo /* u */ }; -struct KEYMAPE (6) cXmap = { - 6, - 6, +static PF cXz[] = { + repeat /* z */ +}; + +struct KEYMAPE (7) cXmap = { + 7, + 7, rescan, { { @@ -250,6 +254,9 @@ struct KEYMAPE (6) cXmap = { }, { '^', 'u', cXcar, NULL + }, + { + 'z', 'z', cXz, NULL } } }; diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index 3b8a4d87..78b63e44 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -1625,3 +1625,176 @@ TEST_CASE("C-l cycles recenter middle/top/bottom") CHECK(r3 > r2); // bottom below top } + +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, "t.txt", std::chrono::seconds(8))) { + (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 "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 (C-n twice lands two lines down)") +{ + 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); // landed on line 3 + CHECK(content.find("b") != std::string::npos); +} + +TEST_CASE("C-x z after undo continues undoing (not a redo)") +{ + auto dir = make_temp_dir(); + std::ofstream(dir / "t.txt") << "start\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, "start", std::chrono::seconds(8))) { + // three separate inserts (C-f between them => separate undo records) + (void)!::write(master, "A\x06" "B\x06" "C", 5); + (void)!::write(master, "\x18u", 2); // C-x u : undo once + (void)!::write(master, "\x18z", 2); // C-x z : repeat undo + (void)!::write(master, "z", 1); // z : repeat undo again + (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); + // undo + two repeats undo all three inserts -> back to "start"; A/B/C gone. + CHECK(content.find("start") != std::string::npos); + CHECK(content.find('A') == std::string::npos); + CHECK(content.find('B') == std::string::npos); + CHECK(content.find('C') == std::string::npos); +} + +TEST_CASE("C-x z inside a macro does not crash on replay") +{ + auto dir = make_temp_dir(); + std::ofstream(dir / "t.txt") << "L1\nL2\nL3\nL4\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, "L1", std::chrono::seconds(8))) { + (void)!::write(master, "\x18(", 2); // C-x ( start macro + (void)!::write(master, "\x01\x0e", 2);// C-a C-n + (void)!::write(master, "\x18z", 2); // C-x z (recorded) + (void)!::write(master, "\x18)", 2); // C-x ) end macro + (void)!::write(master, "\x18" "e", 2);// C-x e replay (old code crashed here) + drain(master, 400); + (void)!::write(master, "MARK", 4); // still responsive? + (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("MARK") != std::string::npos); // editor survived + responsive +} + +TEST_CASE("C-x z preserves the numeric prefix of the repeated command") +{ + auto dir = make_temp_dir(); + std::ofstream f(dir / "t.txt"); + for (int i = 1; i <= 9; i++) f << "L" << i << "\n"; + f.close(); + 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, "L1", std::chrono::seconds(8))) { + (void)!::write(master, "\x01", 1); // C-a (line 1) + (void)!::write(master, "\x1b" "3", 2); // M-3 : prefix arg 3 + (void)!::write(master, "\x0e", 1); // C-n x3 -> line 4 + (void)!::write(master, "\x18z", 2); // C-x z repeats (arg 3) -> line 7 + (void)!::write(master, "\x0b", 1); // C-k kill line 7 ("L7") + (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); + // repeat reused n=3, so C-n moved 3 then 3 -> line 7 killed; if n were dropped + // to 1 it would have landed on line 5 instead. + CHECK(content.find("L7") == std::string::npos); + CHECK(content.find("L4") != std::string::npos); + CHECK(content.find("L5") != std::string::npos); +}