Implements `docs/journey-stage1b3-welcome-framing.md` (approved at
revision 4, after three review rounds). The last of the 1b split.
`COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, no
cheat sheet reachable from inside the editor, and `M-x` — the only door
in — discoverable only by already knowing about it. A fresh `pmacs` now
greets an untouched `*scratch*` with three lines naming `M-x` and four
real bindings, and `M-x help` renders a cheat sheet.
The startup seam is the substance. No constructor is the right hook:
`EditorState::open` calls `new` before resolving its target, the daemon
constructs one too, `init.lua` runs inside `new`, and desktop restore
happens later still. So `run()`'s terminal-free prefix is extracted into
`prepare_startup`, which `run` delegates to, and the greeting happens
there — after config, after attach dispatch resolves to local, and
after desktop restore. Extracting it is also what makes the wiring
testable: with the greeting called by hand from tests instead, deleting
the production call would leave every assertion green while shipping no
welcome.
Lua owns what is said, Rust owns when and where. `pmacs.welcome.entries`
is a structured list that both renders the text and drives the binding
checks — scraping the rendered prose would be ambiguous, since `C-c c`
is two chords and nothing in the text marks the boundary.
The greeting is deliberately NOT written through
`set_generated_contents`: that would lift read-only, discard history and
mark the buffer generated, all wrong for the buffer journey step 5
requires the user to type into immediately. It is left unmodified so it
does not look like unsaved work.
`M-x help` renders through `editor.describe-command`'s existing `*help*`
mechanism via a new `pmacs.editor._show_help` seam, rather than growing
a second help surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Three of the five review findings land here; the other two are recorded
as named deferrals in the framing on the dired branch.
Finding 1: the command comment cited "acc4", a name from a draft scheme
that no test carries. It now names the real test, and the comment splits
the shadowing consequence into the two cases that actually exist -- a new
bare name that matches an entry (shadowed) versus one that matches
nothing (creates normally) -- each pointing at its test.
Finding 2: the everyday new-file flow had no test. Typing a bare name
that is not a subsequence of any entry is the path users hit first, and
the only route combining free text with a relative join; every existing
new-file test used a name containing a separator.
find_file_bare_new_name_creates_in_the_root covers it, asserting the
parent is the prompt's root so the join itself is pinned.
Finding 3: the failure arm was never exercised, and as the review noted,
deleting the pcall would have passed the whole suite. Accepting a
directory candidate reaches display_file, whose load fails because
File::open on a directory succeeds and the read returns EISDIR;
find_file_accepting_a_directory_reports_instead_of_raising pins that this
surfaces as the command's status message, leaves the active buffer alone,
and closes the prompt. Verified by manual revert: with the pcall replaced
by a direct call, that test and only that test fails. scripts/bite could
not isolate it, since the guard and its test have no separating commit.
Finding 4 is documented at the command rather than left implicit:
accepting on empty input opens the first-sorted candidate, because
fuzzy_score returns Some(0) for an empty needle and filter_and_sort
breaks the tie lexicographically, so dotfiles lead and a directory can
lead. M-x and switch-buffer share the mechanism, so it is inherited
rather than introduced, and it is listed in the framing beside the
accept-semantics change that would close it.
Dired arc Stage 0 (docs/dired-framing.md section 10, Q#DR11). Until now
pmacs had no discoverable way to open a file by path: no find-file
command and no C-x C-f binding, so a file entered a session only from
the CLI, an LSP jump, a project-search visit, or C-x C-r, whose prompt
does pass free text through but completes only over the recent list.
The command prompts with completion rooted at the active buffer's
directory, or the process cwd when the buffer has no backing path, and
opens the result through pmacs.window.display_file. A path that does not
exist yet creates a buffer bound to it with the "[new file]" status,
which is Emacs parity and comes from resolve_target_buffer rather than
anything added here. Nothing is written to disk until the user saves.
Two substrate facts shape the design and are documented at the command
rather than left to be rediscovered.
Completion is flat: the files source lists one directory and yields bare
basenames, and a custom function source could not do better, because
sources are called with no arguments and run synchronously outside any
coroutine, so a callback can neither see the input to re-root on nor
await a directory listing. Hierarchical completion is a named Rust
change in the framing.
A selected candidate shadows typed text: recompute_candidates selects
index 0 whenever the candidate list is non-empty, and
resolve_accepted_value returns the candidate over the typed contents. So
typed text reaches the accept handler exactly when the input filters
every candidate away, which for basename candidates under a subsequence
filter means when it contains a separator. That makes the deeper-path
case work verbatim and leaves one hole: a new bare name that is a
subsequence of an existing entry opens the existing file. The acceptance
pins that as a decision rather than an accident; closing it needs a Rust
change to accept semantics that Stage 0 deliberately does not make.
A leading tilde is expanded before the path reaches the core, because
get_or_load_buffer normalizes the path it stores but loads from the raw
one -- so an unexpanded tilde path deduplicates against an already-open
buffer yet fails to load a file that is not open yet.
The prompt field starts empty and names its root in the prompt string
instead: any prefill would contain a separator and silently disable
completion.
Acceptance is dispatch-driven throughout -- a real C-x C-f, real typing,
a real RET -- so a dead binding cannot pass vacuously and the Lua
lifecycle accept(), which bypasses the path interactive input takes, is
not used.
A third registry beside CommandRegistry and HookRegistry, per
docs/config-registry-framing.md. Unblocks the per-buffer auto-pair
toggle, the first of the five backlog items the missing config surface
was gating.
Substrate (src/config_registry.rs):
* ConfigRegistry keyed by name with definition order preserved, R42
mandatory descriptions, R50 typo detection, duplicate rejection,
and SourceLocation provenance -- the command/hook vocabulary.
* Closed scalar kinds: boolean, integer, number, string, enum. Owned
Rust values; Lua tables, functions and userdata are never stored.
Integer exactness is checked by value, never math.type, so the
luajit and lua54 builds agree.
* Two scopes. get(name, buf) resolves buffer-local -> global ->
default; get(name) with no buffer resolves the global chain only
and never consults an ambient buffer. Buffer-locals live in a
registry-owned side table purged at after_buffer_removed, beside
the keymap purge already there.
* An override is ALWAYS stored, even when equal to the value it
shadows; only value_epoch and listener dispatch key on effective
change. Without this a buffer pinned to the current value stores
nothing and a later global set flips it -- the pin silently never
existed. equal_valued_local_override_is_still_stored_and_shields_buffer
fails against the naive reading.
Bindings (src/lua_bindings/config.rs):
* define/get/set/set_local/reset/is_set/describe/list/on_change.
Spec tables are read raw, so neither an unknown key nor a
metatable-provided value can smuggle a field in.
* Listeners commit inside the borrow, snapshot, drop the borrow, and
only then re-enter Lua -- verified by holding the borrow and
watching the test panic with "RefCell already borrowed". A raising
listener is logged without blocking later ones or rolling back, and
a depth bound turns an accidental cycle into a pointed error.
Listeners persist until explicitly disposed; there is no Gc path,
matching the rest of the codebase.
* StartupOnly freezes off the existing InitCompleteFlag at write
time, so this arc adds no editor.rs call at all.
Adopters, each defining its own key so SourceLocation names the owning
module: editing.auto-pair (pair.lua, read per-buffer against the typed
edit's SOURCE buffer), editing.trim-on-save (editops.lua),
autosave.interval-ms (autosave.lua). No public function is removed or
deprecated, and both migration wrappers keep their legacy coercion --
trim_on_save("yes") still enables, interval_ms(1500.7) still floors to
1500 -- coercing before handing the strict registry a conforming value.
M-x describe-setting renders into *help*, modeled on describe-command.
Framing revision 3 records four defects implementation found in the
document itself: acceptance 30 and 31 contradicted each other; the
planned builtin/runtime/config.lua had nothing to hold and would have
broken the source-location contract had it held the one helper it might
have; F5 asked define to police a call it cannot see, moved to
set_local; and list() ordering was underspecified.
No protocol change; SUPPORTED stays [6..18]. No wire surface. Zero
changes to src/editor.rs.
Gates: fmt, clippy -D warnings, --lib (1683), --lib --features crdt
(1857), the new config_registry_acceptance (13) plus auto_pair (45),
editops (72), autosave (29) and m9_6 (25), m4 --skip basedpyright
(114), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), the lua54 backend build,
and the full workspace sweep (2795 tests, exit 0). git diff --check
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finding-by-finding (framing revision 7; every fix bite-verified via
scripts/bite against the pre-fix tree):
1. Stored coordinates must be finite integers, and both cursor walks
are movement-bounded — they clamp at EOF, and the column walk
clamps at the target row's EOL instead of marching onto later
rows. An astronomical %d+ capture can no longer hang the editor.
2. The grep panel gains the same immediate buffer.after-edit
recovery trigger as the compile slots: M-x buffer.undo after a
COMPLETED search is marked synchronously.
3. The rustc arrow rule uses the framing's ([^:]+) spelling — paths
with spaces capture whole.
4. All pattern captures are collected (index 4+ reads the real
capture, not nil-as-column-0); capture indexes must be positive
integers; a rule naming a column its match didn't produce rejects
the match.
5. emit_text_raw is module-local — a user global could shadow the
helper the terminal-event path depends on, and its error consumed
the terminal event before pump cleanup/forget ran.
6. stdin/group spec fields reject wrong Lua types as hard errors;
group is matched as a raw Value because mlua's bool conversion
applies Lua truthiness ("true" would silently coerce).
7. resync also nils the public line_start_byte — total pre-marker
anchor invalidation includes the byte anchor.
8. The inherited cwd resolves through
pmacs.instance.identity().working_directory; the header always
names a real path and relative error files get an explicit base.
9. New AnsiParser::finish() + parser:finish() (additions #5): a
truncated multibyte sequence at process EOF surfaces as U+FFFD
before the exit marker instead of vanishing.
10. The built-in default rules are a private deep copy — in-place
mutations of the public table no longer survive the "using
built-in defaults" degradation.
Eleven new tests (r1f1a/b–r1f10); bites: 9 fail against pre-fix
compile.lua, r1f2 against pre-fix default.lua, r1f6 against pre-fix
lua_bindings/mod.rs — all clean assertion failures. Gates: fmt,
clippy workspace all-targets, lib 1522, crdt lib 1696, compile
acceptance 45, crdt acceptance 1, m4 101, GPU 59, workspace sweep
2493/0, git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
builtin/runtime/compile.lua (Q#CM1-CM6, CM8-CM11): streaming
intercept-read-only *compilation* / *shell-command* slots fed by a
Lua-side ANSI parser (SGR to overlay spans, CR/BS/erase progress
collapse); once-per-newline error parsing over a validated,
fail-closed rule table (rustc arrows, gcc/clang, Python, generic;
severity override + keyword sniff; sub-1 captures discarded);
buffer-revision external-edit guard with desync marker and anchor
epochs, checked before every producer write, before byte-anchor
use, and immediately via buffer.after-edit; unified error.next /
error.previous dispatcher with last-claim-wins sources and a
diagnostics fallback (M-g n/p unbind-then-rebind — hence the
loader's ordering contract after lsp.lua; C-x ` bound; M-! bound);
buffer-local RET/n/p/g/q/C-c C-k plus all seven undo/redo chords as
status no-ops; tombstoned pump teardown honoring forget's
terminated-only contract; q-target never captures a generated
buffer; overlay retained per incarnation, cleared per run,
re-attached from buffer.after-switch.
builtin/commands/default.lua (Q#CM7): project.search's
*search-results* becomes a first-class locations buffer — read-only
with bypass writes, RET/n/p/q + undo no-ops + round-trip input,
structured-match locations (line-1, match_start as col, paths
resolved against the search root), per-write revision checks so a
batch cannot mask an external edit, on_removed stream cancel +
guards for kill-mid-search, root retention across interactive
supersedes from inside the pathless panel, and an error-source
claim per search.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Arc 2 (docs/kill-ring-framing.md, rev 3 — three review rounds). Kills
accumulate in a ring; consecutive kills append; C-y yanks the head; M-y
right after a yank cycles older entries; C-k (kill-line) exists at last.
The ring is daemon-global (Emacs-daemon model); chains and yank sessions
are per-frontend.
The substrate (Q#KR2): EditorCore.command_history maps FrontendId ->
{this, last} command. Every input path updates it --- the rev-1 design
treated dispatch_key as the only input path and review falsified that
twice:
keybound command dispatch_key Run arm rotate
typed char (round-trip) self-insert fallback rotate
unbound key dispatch_key unbound arm break
GPU optimistic edit handle_remote_crdt_op break
pointer gesture dispatch_mouse + dispatch_pointer break
inbound OS paste unified paste route break
menu item menu_invoke_active rotate
M-x accept pmacs.command.invoke_interactive rotate
invoke_interactive gives Emacs's execute-extended-command semantics
(M-x kill-line then C-k appends; C-k then M-x kill-line does not); the
public pmacs.command.invoke stamps nothing. Wheel scroll deliberately
does NOT break (mwheel-scroll vs mouse-set-point, as in Emacs).
Three shipped bugs fixed en route (Q#KR10):
- Semantic-path Paste was dropped ("no grid-less effect yet"), and the
GPU always negotiates semantic render --- GPU Ctrl-V was a no-op. Paste
is now a dispatcher-level arm serving both attachment kinds.
- That arm keys off the dispatcher's AUTHENTICATED source; the old grid
arm trusted the client-supplied payload frontend_id, letting a forged
id paste into another frontend's active window (unit-tested).
- Paste, M-x-invoked commands, and menu-invoked commands never fired
buffer.after-edit (each runs outside dispatch_key's revision check),
so LSP/syntax/autosave missed those edits. A shared
with_after_edit_check helper now wraps all three sites; scope is
honest --- active-buffer compare, sound for these paths, not a general
any-buffer guarantee (buffer-aware edit epoch deferred).
The ring (killring.lua, Q#KR4-7): entries carry stable monotonic ids.
Append requires last_command in the kill family AND this frontend's
last_kill_id == the head's id --- A-kill/B-kill/A-kill pushes fresh
instead of corrupting B's entry. Yank sessions store {buffer, start,
stop, entry_id, text}: M-y validates last_command + live session + same
buffer + slice(start,stop) == text (out-of-bounds reads as changed ---
pcall'd; an early test caught the guard throwing on an upstream
deletion instead of refusing), rotates by locating the entry_id's
CURRENT position (positions shift under other frontends' pushes; ids
don't), verifies the applied replace (intercepts may alter it; accepted
post-hoc semantics), then goto_byte. Failed kills clear last_kill_id;
failed/refused yanks create no session, so a second invalid M-y cannot
ride the first's name-stamp.
OS clipboard: ring head mirrors to the ACTING frontend's OS clipboard
only (pending_clipboard's existing shape; frontends may be different
machines). External content joins the ring at yank time via the
clipboard_get slot check (an OS copy reaches the daemon only when
pasted). New core seams: clipboard_set(bytes) / clipboard_get.
Lifecycle (Q#KR11): SessionDetached prunes command_history and fires the
new frontend.detached hook (raw id); killring.lua drops that frontend's
tables.
pmacs.killring.max([n]) validated (non-finite rejected --- math.huge
would defeat the cap; shrink trims immediately), default 60.
Deferred, named: word kills (M-d/M-BS/C-BS/C-h/C-DEL discard bytes ---
needs bytes-returning deleters), C-SPC/set-mark, clipboard watching,
ring browser/persistence, C-u C-y / C-M-w, buffer-aware edit epoch,
Lua-visible intercept probe.
Tests: tests/kill_ring_acceptance.rs (24) --- chain mechanics incl. all
break rows, the M-x three-direction matrix, per-frontend interleaving
(A-kill/B-kill/A-kill; stable-id rotation under B's pushes; eviction
mid-session; upstream-edit invalidation), menu Cut via real right-click
+ menu pointer (feeds ring, fires after-edit once, chains with C-k),
external-paste integration, cap validation + shrink-trim, detach
cleanup. Plus daemon unit tests: forged-id paste lands in the
authenticated source's window and leaves the claimed frontend's chain
untouched; optimistic CRDT op breaks only the source's chain.
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 24;
cua 5; query-replace 16; completion 9; autosave 29; desktop 11;
persistence 5; clobber 6; m4 90; m8 10+15; m10/m11 crdt; GPU 58;
git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EditorCore::save() wrote unconditionally via save_atomic and only THEN
recorded the new FileMeta. It never compared the on-disk identity against
the one the buffer read. So another editor's writes, or a `git checkout`,
were destroyed without a word --- the single worst data-loss path in the
editor, on its most-used command.
The comparison seam already existed and no caller used it: FileMeta is
PartialEq (mtime + size, sized so same-second edits still differ) and
file_io::current_meta reads it. The file_io module docstring even said
callers "should" compare before saving. Nobody did.
save() now refuses when writing would destroy content the buffer has
never seen:
* the buffer recorded a meta and the on-disk meta differs --- someone
else wrote the file;
* the buffer recorded NO meta (a `[new file]`, or a path set without
reading) yet a file now exists --- it was created underneath us.
A *missing* file is not a clobber: there is nothing to destroy, so
recreating a deleted file saves normally. An unstattable path falls
through and save_atomic reports the real error.
On refusal the status line says what happened and how to override, the
buffer keeps its unsaved edits, and `buffer.after-save` does not fire.
`M-x buffer.save-anyway` (ed.save_ignoring_disk_changes) overwrites
deliberately and re-syncs the meta, so an ordinary save works again.
Named `buffer.save-anyway`, not `save-buffer-anyway`, for two reasons: it
belongs in the `buffer.` namespace next to `buffer.save`, and the latter
outranked `buffer.save` as an M-x completion for "save" (which a lib test
caught).
This is the bug the autosave arc kept circling: Q#AS5's Fresh/Stale guard
refuses to auto-offer a recovery for an externally-changed file, but
nothing stopped save() from overwriting that same file.
Tests: tests/save_clobber_guard_acceptance.rs (6) --- refuses and leaves
their content intact, after-save does not fire on refusal, save-anyway
overwrites and re-syncs meta, unchanged files save repeatedly (the guard
must not trip on our own writes), a deleted file is recreated not
refused, and a `[new file]` buffer refuses once someone else creates the
file. Verified the tests bite: 4 of 6 fail with the guard disabled.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; clobber 6; m1/m3
/m4 90/m5.8/m7.8/m8 10+15; autosave 29; desktop 11; persistence 5;
query-replace/completion/listview/overlay/cua green; GPU 58;
git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emacs query-replace, built on isearch with zero protocol change
(framing: docs/query-replace-framing.md).
- search.rs: find_first_from (literal) + find_first_regex_from (cached
engine, zero-width-skip) + compile_search_regex (shared smart-case
compile). Q#QR2's forward-scan-past-replacement primitive.
- QueryReplaceSession + core methods (editor_core.rs): begin (invalid
regex refuses, Q#QR2), replace/skip/all/replace-and-quit/finish;
matches run forward from next_from on the LIVE buffer, so offset
shifts and never-re-matching-replacements (a->aa) fall out for free;
current match highlighted via a single-element search_store set
(SearchMatchActive, both frontends free) + cursor reveal; quit keeps
replacements, only nothing-matched restores origin (Q#QR10).
- Dispatcher shadow (editor.rs): QueryReplaceKey (y/SPC, n/DEL, !, .,
q/RET/Esc/C-g) + dispatch_query_replace_key, the 5th modal shadow;
added to dispatch_idle disjunction (GPU round-trips keys) and fires
buffer.after-edit itself (Q#QR1 — a shadow returns before the normal
post-command check; once per !-batch).
- Lua: ed.query_replace_start/query_replace_active; query-replace /
query-replace-regexp commands (chained minibuffer.read, separate
from/to history buckets, empty-from reject / empty-to deletion);
M-% / C-M-% bindings.
- Per-match prompt via core.status → v15 StatusFacts.message band.
Tests: 7 core unit + 11 dispatch_key acceptance (replace/skip/!/./quit,
nothing-matched restore, empty-to deletion, a->aa non-loop, regex incl
invalid, after-edit fires, dispatch_idle gate, explicit M-% AND C-M-%
binding tests) + 5 search unit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the gutter with the last two of the framed modes (Q#UX4):
- Relative: each line shows its distance from the cursor line (cursor = 0).
- Hybrid: cursor line shows its absolute number, others relative
(Vim number + relativenumber).
`LineNumberMode` gains `Relative`/`Hybrid` + `number_for(line, cursor_line)`
(the per-line displayed value) and `is_on()`. `paint_line_number_gutter`
now derives each number from the mode and the cursor's buffer line
(`text_view.line_at_offset(cursor)`); the TUI re-renders the whole frame on
cursor motion, so relative numbers track the cursor for free. Gutter width
is sized by `digits(line_count)` for every on-mode, so the text never
jitters as the cursor moves.
Mode selection (chosen over a 4-way cycle): `window.toggle-line-numbers`
stays a binary off/absolute toggle; a new `window.set-line-numbers` opens
the minibuffer with an arrow-navigable completion dropdown
(off|absolute|relative|hybrid) to pick a mode directly. `set_line_numbers`
accepts all four; the getter returns them.
No protocol change here — the GPU half (which needs the mode over the wire,
protocol v14) follows. Test: number_for across all modes. fmt + clippy
clean both flavors; 1446 lib tests pass. Needs a TUI eyeball.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Introduce a reserved left gutter column with absolute line numbers in the
TUI/grid frontend — the foundational piece of the UX arc (docs/ux-arc-
framing.md). Default OFF (Emacs tradition), so zero layout/coordinate
change until a window opts in.
- window.rs: LineNumberMode { Off, Absolute } + per-window `line_numbers`
field + `gutter_width()` (digits(line_count) + PAD) + `decimal_digits`.
- editor.rs: the gutter is one viewport shift at the paint site
(cell_origin.col += gutter_w, cell_size.cols -= gutter_w) — every
viewport-relative painter (text, syntax, diag underline, search) stays
gutter-agnostic. The sites that read rect.origin.col directly get a
manual +gutter_w: cursor placement, local selection, mouse hit-test
(a gutter click maps to line start, Q#UX6). paint_line_number_gutter
writes right-aligned dim digits alloc-free.
- overlay_paint.rs: remote-presence cursor/selection shift by gutter_w.
- Lua: pmacs.window.set_line_numbers/line_numbers +
window.toggle-line-numbers command.
No protocol/daemon change (frontend-local, Q#UX1). Tests: gutter render
(right-aligned digits + past-EOF blanks) + decimal_digits.
Validated: fmt clean; clippy --lib clean both flavors; 1439 lib tests pass
both flavors. Needs a human eyeball (coordinate-math change) before the PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
The Lua glue that gives the menu real items to surface.
- `edit.copy/cut/paste/select-all` commands (Q#CM6) over the core
clipboard, with the Emacs kill/yank bindings `M-w`/`C-w`/`C-y` and
`C-x h` (the CUA trio's keys are already bound: `C-a` line-start,
`C-v` page-down).
- `pmacs.lsp.active_attachment()` (Q#CM5): a pure, side-effect-free
attachment lookup for the menu's `symbol`/`diagnostic` visibility
checks. Unlike `attached_for_active`, it never triggers an attach just
because the menu opened.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Wires regex matching into the search session and the terminal
frontend.
SearchSession gains `regex` and `invalid` flags. search_begin takes a
`regex` argument; recompute dispatches find_all_regex (regex) vs
find_all (literal), recording `invalid` when the pattern won't
compile (an invalid pattern clears the matches and shows [invalid]
rather than a stale count). search_toggle_regex flips the mode and
re-runs the current query.
Input: C-M-s / C-M-r start a regex search (search.forward-regex /
search.backward-regex commands → ed.search_start(forward, regex)).
M-r toggles literal <-> regex mid-search — a new SearchKey decoded in
dispatch_search_key, so it works the same in both frontends (the GUI
already round-trips every key while searching). The TUI prompt reads
"Regex I-search:" in regex mode and "[invalid]" when the pattern
won't compile.
Multi-line: SearchView now washes each row a match spans, mirroring
paint_local_selection's per-row clip (newline excluded so a spanning
match doesn't paint a phantom trailing cell). Single-line matches —
every literal match — touch exactly one row, unchanged. The GPU
already fans multi-line ranges per-line, so it needs no change here.
Tests: regex match / smart-case / invalid-flags-and-recovers /
toggle-reinterprets-query (core); C-M-s starts regex + M-r toggles
mid-search (dispatch); multi-line per-row wash (SearchView render).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the live-typing half of in-buffer search for the terminal
frontend, on a frontend-agnostic core so the GPU (next commit) can
share it.
EditorCore gains a `search: Option<SearchSession>` (query + origin
cursor + direction) and the `search_*` methods that drive it:
begin records the origin, input_char/backspace re-run `find_all`
against the origin buffer and refocus the match nearest the origin
(failing searches anchor the cursor back at the origin), step walks
the store's active match (wrapping, also usable post-accept), and
finish either keeps the cursor + matches (accept) or restores the
origin and clears them (cancel). The matches live in the shared
`search_store`, so the decorations producer and the TUI SearchView
light up live as you type.
Input routing is intercepted in `EditorState::dispatch_key`: while
a search runs, every key flows through `dispatch_search_key`
(SearchKey::from_chord) instead of the global keymap — printable
chars extend the query, C-s/C-r (and Down/Up) step, RET accepts,
C-g/Esc cancel, BS shortens. This is the same dispatch path the
daemon's `FrontendEvent::Key` round-trip uses, so the daemon-side
search already works; the GPU just needs to route keys + show the
prompt (commit 4). The TUI paints an `I-search: <query> (n/m)`
prompt on the bottom row while keeping the terminal cursor in the
buffer at the active match.
C-s / C-r start the search (search.forward / search.backward Lua
commands → ed.search_start). Both keys were free in the default
map (save is C-x C-s, redo is C-x r), so isearch lands without
disturbing the CUA / Emacs editing keys — no cursor.right rebind
needed (the framing doc had flagged C-f for veto; C-s is cleaner
and Emacs-faithful).
Any edit now marks the buffer's matches stale in apply_active_edit
(M11.8), closing the headline "stale-after-edit linger" bet:
accepted highlights vanish the moment the text they described
changes, rather than painting at wrong offsets.
Tests: EditorCore-level (begin/type/step/wrap/focus-from-origin/
cancel/accept/backspace/smart-case/stale-on-edit) and dispatch-
level acceptance (C-s drives the whole loop; Esc restores; query
keys never self-insert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing over a selection composed two edits in Lua —
delete_region() + insert_char() — so it recorded two undo steps:
one undo left the half-replaced text, two restored the original.
Undo granularity is per apply_edit in both modes (v0.1 pushes one
UndoEntry per edit; CRDT commits per edit via export and groups by
commit, with record_checkpoint unused), so the fix is to make
type-over one edit.
New core EditorCore::insert_char_over_region emits a single
EditOp::Replace when a region is active (cursor past the inserted
bytes, selection cleared) and delegates to insert_char otherwise.
The three type-over commands (buffer.newline / tab / self-insert)
call it via a new Lua binding instead of the delete+insert pair.
delete_region and insert_char are unchanged for their other
callers; region-aware backspace/delete already emit one op.
Verified one undo unit in BOTH modes (dual_mode
replace_is_a_single_undo_step covers v01 + crdt — a CRDT Replace is
delete-then-insert internally but one commit) plus an end-to-end
acceptance test through the key-dispatch path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- S-<arrows>/S-<home>/S-<end> (+C-S word/paragraph variants) extend a
selection; the TUI grid paints it reverse-video; double-click
selects the word at point.
- Backspace / Delete consume the active region (delete_region first,
falling back to single-codepoint semantics).
- Typing replaces the region: buffer.self-insert / newline / tab
delete_region before inserting. pmacs-gpu cooperates by
round-tripping keys while an own-window selection is active, so
the region-aware commands run instead of a raw optimistic op.
- tests/cua_region_acceptance.rs drives the real dispatch path:
select -> BS/DEL/char/Enter, plus the no-region fallbacks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Land the Model Context Protocol (MCP) integration as a transport binding,
not a built-in feature. Six Lua functions plus userdata methods expose
the substance of three MCP feature areas (resources, tools, prompts), a
notification dispatcher, and a non-trivial AI-assistance example package
that meets the architectural ship gate (spec/pmacs-spec.tex:1572): zero
direct calls into the Rust core, zero special-cased MCP handling outside
the public API, source under 2000 lines of Lua.
The M9.5 -> M9.6 -> M9.7 -> M9.8 layered composition validates the claim
"AI is a transport binding, not a feature" -- pmacs-mcp-ai composes with
pmacs-mcp-prompts.render and inherits notification handling transitively
through M9.7's package, demonstrating that the AI domain is a layer
above MCP, not a thread woven through the core.
Subtask shape:
M9.1 stdio transport + initialize handshake + restart policy
M9.2 resources with in-flight + settled cache and per-uri invalidation
M9.3 tools with isError-vs-JSON-RPC-error semantics + cancellation
M9.4 prompts with required-argument validation
M9.5 notification dispatcher (on_notification, off_notification)
M9.6 tools-as-commands fixture package + 12 audit findings disposed
M9.7 prompts-as-result-buffers fixture package + tree-sitter-md grammar
M9.8 AI-assistance fixture package (363+ LoC; 17/17 acceptance tests)
M9.9 formal package audit -- PASS on all three criteria
M9.10 release: TRANSITION-M9.md + MCP-for-package-authors guide
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>