Merge pull request #73 from levineuwirth/session-gpu-minibuffer

GPU minibuffer: prompt line + completion dropdown (protocol v12)
This commit is contained in:
Levi Neuwirth 2026-07-03 09:51:44 -04:00 committed by GitHub
commit aae37d821d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 710 additions and 16 deletions

View File

@ -0,0 +1,161 @@
# GPU minibuffer — framing + as-built
pmacs-gpu couldn't render the minibuffer, so `M-x`, find-file,
switch-buffer, and the LSP rename prompt were invisible in the GUI —
both prior arcs (search, context menu) had to route *around* this gap.
This arc closed it. The build landed close to the framing (the third
surface in the `SearchPrompt` / `MenuPrompt` family); the "As-built"
notes record where it differed.
Decided (with the user):
- **Render-only.** The minibuffer logic already lived entirely in the
core; the GPU *already round-tripped keys* while one was open
(`dispatch_idle` goes false). So this was a wire-message + GPU
render-surface arc — no new input logic.
- **Vertical dropdown** for candidates (Vertico / Telescope style), not
the TUI's inline `[selected]`. The GPU leads the TUI here; the
cross-frontend divergence is accepted (discussed against how Emacs and
Neovim converged on a vertical list).
## What the core already exposed
A single **global** `EditorCore::minibuffer` (not per-buffer). While a
prompt is open, `MinibufferSession` carries everything a renderer needs:
`prompt` (e.g. `"M-x "`), the typed `input` (`minibuffer.contents()`),
the `cursor` (byte offset), `candidates: Vec<String>` (already
filtered + fuzzy-sorted best-first, plain strings), and
`selected: Option<usize>`. Input is fully handled by
`MinibufferAction::from_chord` (RET accept, TAB complete, `C-g`/Esc
cancel, Up/Down history, `M-n`/`M-p` cycle candidates, motion,
self-insert). These fields are all public, so the producer reads them
directly — **the core was untouched**, exactly the family bet.
## Architecture
### Q#MB1 — A `MinibufferPrompt` semantic message, mirroring the family
`InstanceMessage::MinibufferPrompt` (protocol v12), produced by
`semantic_render::minibuffer_prompt_msg` with the same cached-compare
suppression as `search_prompt_msg` / `menu_prompt_msg`, daemon-gated
`>= 12`. The GPU mirrors it into a `MinibufferLocal` and renders. The
minibuffer is global, so the message is **bufferless** — the producer
caches a single value (not a per-buffer `HashMap`) and emits only from
the active-buffer viewport so the bufferless message ships once per
frame.
### Q#MB2 — The prompt line lives in the bottom band
When the minibuffer is open, `compose_status_left` returns exactly
`prompt + input`, taking over the band ahead of the search prompt and
status. The buffer caret is hidden; a **band caret** quad draws at the
input cursor. **As-built:** the caret x uses the band font's monospace
advance — the shaped status-left width ÷ its char count, times
`prompt_chars + cursor` — rather than a per-glyph measurement (Q#MB4).
Exact for the ASCII command names / filenames that dominate; a
multibyte-exact caret is deferred.
### Q#MB3 — The candidate dropdown floats above the band
A vertical popup anchored just **above** the band at the input's left
edge, **growing upward, best match at the top**, the `selected` row
highlighted. **As-built:** it's a *third* `TextRenderer`
(`mb_text_renderer`) over bg/selection quads — the menu's popup pattern,
reusing the menu's colors — not "a second" renderer (the menu already
owns one). It only appears when there are candidates, so free-form
prompts (`project.search`, rename) stay a single line. The list is a
**scrolled window**: the producer sends a bounded slice
(≤ `MB_VISIBLE` = 10) around `selected` plus `total`, so a
1000-command `M-x` ships ~10 strings per keystroke, not 1000, and the
selected row stays in view as you cycle.
### Q#MB4 — Cursor as a codepoint offset
The message carries `cursor` as the count of codepoints before the
cursor in `input` (computed in the producer via `char_indices`). See
Q#MB2 for how the GPU turns it into the band caret x.
### Q#MB5 — Forwarding the chords that *open* the minibuffer
The GPU withholds command chords by default, so `M-x` and the `C-x`
prefix never reached the daemon — the minibuffer couldn't even be
opened. `is_minibuffer_open_chord` now forwards them: `M-x`
(→ execute-command, from which any command incl. find-file /
switch-buffer is reachable by name) and the **`C-x` prefix** (so the
bound `C-x b` / `C-x C-f` work). Both flip the daemon into a state
(`minibuffer active` / `pending prefix`) that makes `dispatch_idle` go
false, after which the intercept gate round-trips every key. Mirrors
`is_search_entry_chord` / `is_clipboard_chord`; no optimistic local flip
(the search precedent). General Emacs-chord forwarding stays a separate
thread; this arc forwarded only what opens a prompt.
## Wire (protocol v12)
Additive over v11; `SUPPORTED = [6..12]`:
```
InstanceMessage::MinibufferPrompt {
prompt: Option<String>, // None = minibuffer closed (clears the GUI)
input: String,
cursor: u32, // codepoints before the cursor in `input`
candidates: Vec<String>, // windowed slice (≤ MB_VISIBLE)
selected: Option<u32>, // highlighted row *within* `candidates`
total: u32, // full candidate count
}
```
Cached-compare suppressed like `SearchPrompt`; first sight while closed
stays silent. Daemon-gated `>= 12`. The TUI ignores the variant (it
paints the minibuffer via its own bottom row). The whole shape (incl.
candidates) landed at v12, so the dropdown phase needed no second bump.
## Phasing (delivered; each commit binary-build-green)
1. **Wire + prompt line** — protocol v12 + producer + daemon gate + GPU
handler + the band prompt line (prompt + input + caret) + the
opening-chord forwarding (`M-x`, `C-x`). `M-x` opens, typing works,
RET runs. User-validated.
2. **Candidate dropdown** — GPU-render-only: the vertical popup above the
band, consuming the candidates already on the wire. User-validated.
3. **Docs** — this consolidation.
Phase 1 made the minibuffer *work* in the GUI; phase 2 made it
*pleasant*. The v12 bump means the daemon + pmacs-gpu must both be
rebuilt to negotiate it.
## Categorical bets (all held)
- **The family pattern generalized a third time.** `MinibufferPrompt` +
the band/popup surfaces dropped in as the same shape as search and the
menu; the producer/cached-compare/gate machinery reused cleanly and
the core stayed untouched.
- **A windowed candidate slice was enough.** ~10 around the selection
keeps the wire cheap and the dropdown correct as you cycle.
- **Forwarding only the opening chords was the right cut.** `M-x` + `C-x`
make the minibuffer reachable without the general chord-forwarding can
of worms.
## As-built divergences from the framing
1. **Caret via monospace advance** (Q#MB2/Q#MB4), not a per-glyph width
measurement — simpler, exact for the ASCII case that dominates.
2. **A third `TextRenderer`** for the dropdown, not "a second" — the menu
already owns its own popup renderer; the dropdown got a dedicated one
and reuses the menu's colors.
3. **Dropdown ordering pinned to best-at-top, growing upward** — the
framing left it unspecified; top-to-bottom reading with `M-n` moving
the highlight down was the least surprising.
4. **`total` ships but isn't rendered** — the "i/total" hint is deferred
(below).
## Deferred (named, not silently dropped)
- General Emacs-chord forwarding in the GPU (every binding, not just the
prompt-openers) — the separate thread this arc brushes against.
- The "i/total" count hint and a Telescope-style preview pane beside the
list.
- Candidate annotations (kind / docstring) — the core's candidates are
bare strings today.
- Bringing the TUI's inline `[selected]` up to the same dropdown (unify
the frontends); for now the GPU leads.
- Multibyte-exact caret positioning in the band.

View File

@ -124,6 +124,17 @@ const MENU_MAX_WIDTH: f32 = 380.0;
const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98];
const MENU_SELECTED_BG: [f32; 4] = [0.20, 0.40, 0.66, 1.0];
const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0];
// Minibuffer completion dropdown (Q#MB1). A vertical list anchored just
// above the bottom band, best match at the top; reuses the menu popup's
// colors. Width tracks the widest candidate (measured from the shaped
// buffer).
const MB_DROP_ROW_HEIGHT: f32 = 20.0;
const MB_DROP_FONT_SIZE: f32 = 13.0;
const MB_DROP_LINE_HEIGHT: f32 = 20.0;
const MB_DROP_PAD_X: f32 = 10.0;
const MB_DROP_MIN_WIDTH: f32 = 160.0;
const MB_DROP_MAX_WIDTH: f32 = 480.0;
const QUAD_SHADER: &str = r"
struct VertexOut {
@builtin(position) pos: vec4<f32>,
@ -555,6 +566,10 @@ struct State {
/// the buffer name; the matches highlight via `SearchMatch`
/// decorations.
search_prompt: Option<SearchPromptLocal>,
/// Q#MB1 — the live minibuffer (protocol v12), or `None` when
/// closed. The prompt+input render in the bottom band; the
/// candidates (when present) render as a dropdown above it.
minibuffer: Option<MinibufferLocal>,
/// Q#CM1 — the live context menu (protocol v11), or `None` when
/// closed. The rows + highlight come from `MenuPrompt`; the popup
/// draws at the pixel of the right-click.
@ -570,6 +585,14 @@ struct State {
menu_text_renderer: TextRenderer,
/// Popup background / highlight / separator quads (Q#CM1).
menu_bg_vertex_buffer: ReusableVertexBuffer,
/// Shaped candidate text for the minibuffer dropdown (Q#MB1), one
/// line per candidate.
mb_buffer: Buffer,
/// Dedicated text renderer for the minibuffer dropdown (its own
/// layer over the buffer, like the menu's).
mb_text_renderer: TextRenderer,
/// Minibuffer dropdown background + selection quads (Q#MB1).
mb_bg_vertex_buffer: ReusableVertexBuffer,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
@ -598,6 +621,20 @@ struct SearchPromptLocal {
invalid: bool,
}
/// The live minibuffer (Q#MB1, protocol v12), mirrored from a
/// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw
/// in the bottom band with a caret; `candidates` (a windowed slice) feed
/// the dropdown.
#[derive(Clone, Debug, PartialEq)]
struct MinibufferLocal {
prompt: String,
input: String,
cursor: u32,
candidates: Vec<String>,
selected: Option<u32>,
total: u32,
}
/// The live context menu (Q#CM1, protocol v11), mirrored from a
/// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px`
/// (the right-click pixel, remembered locally — the daemon never sees
@ -828,6 +865,23 @@ impl ApplicationHandler<AppEvent> for App {
return;
}
// Minibuffer-opening chords (Q#MB1): M-x (execute-command)
// and the C-x prefix. Forwarded though otherwise withheld
// so the GUI can open a prompt / enter a prefix; the
// daemon then flips `dispatch_idle` false (minibuffer
// active / pending prefix) and the intercept gate
// round-trips every following key. No optimistic local
// flip (the search-entry precedent).
if is_minibuffer_open_chord(pkey, pmods) {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (minibuffer open) failed: {e}");
}
return;
}
// Session B2 forwards cursor motion + plain text editing
// (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/
// Meta chords are withheld — they drive commands and
@ -1555,6 +1609,9 @@ impl State {
// Q#CM1 — a second renderer so the menu draws as a top layer.
let menu_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Q#MB1 — a third renderer for the minibuffer dropdown layer.
let mb_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
let quad_renderer = QuadRenderer::new(&device, surface_format);
let squiggle_renderer = SquiggleRenderer::new(&device, surface_format);
@ -1595,6 +1652,15 @@ impl State {
Some(MENU_MAX_WIDTH),
Some(config.height as f32),
);
let mut mb_buffer = Buffer::new(
&mut font_system,
Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT),
);
mb_buffer.set_size(
&mut font_system,
Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32),
);
buffer.set_text(
&mut font_system,
initial_text,
@ -1667,11 +1733,15 @@ impl State {
status_left_text: String::new(),
status_facts: None,
search_prompt: None,
minibuffer: None,
menu: None,
menu_anchor_px: (0.0, 0.0),
menu_buffer,
menu_text_renderer,
menu_bg_vertex_buffer: ReusableVertexBuffer::new(),
mb_buffer,
mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
}
}
@ -1692,9 +1762,13 @@ impl State {
/// every key to the daemon's handler instead of optimistically
/// applying it to the buffer.
fn daemon_intercepts_keys(&self) -> bool {
// Q#CM1 — an open menu shadows the keymap like search: every key
// round-trips so the daemon's `dispatch_menu_key` drives it.
self.search_prompt.is_some() || self.menu.is_some() || !self.dispatch_idle
// Q#CM1 / Q#MB1 — an open menu or minibuffer shadows the keymap
// like search: every key round-trips so the daemon's
// `dispatch_menu_key` / minibuffer handler drives it.
self.search_prompt.is_some()
|| self.menu.is_some()
|| self.minibuffer.is_some()
|| !self.dispatch_idle
}
/// Shared eligibility gates for the optimistic edit paths
@ -2474,6 +2548,27 @@ impl State {
self.window.request_redraw();
None
}
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
// None` closes it.
InstanceMessage::MinibufferPrompt {
prompt,
input,
cursor,
candidates,
selected,
total,
} => {
self.minibuffer = prompt.map(|prompt| MinibufferLocal {
prompt,
input,
cursor,
candidates,
selected,
total,
});
self.window.request_redraw();
None
}
_ => None,
}
}
@ -2897,6 +2992,12 @@ impl State {
/// over the band like Emacs's echo area, returning to the buffer
/// name + modified dot (v8 `StatusFacts`) when the search ends.
fn compose_status_left(&self) -> String {
// Q#MB1 — an open minibuffer takes over the band: prompt + input
// (the candidates render separately as a dropdown). Measured by
// the band caret, so it must stay exactly `prompt + input`.
if let Some(mb) = self.minibuffer.as_ref() {
return format!("{}{}", mb.prompt, mb.input);
}
if let Some(sp) = self
.search_prompt
.as_ref()
@ -3052,6 +3153,75 @@ impl State {
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per
/// candidate, best match first. Empty when there are no candidates.
fn refresh_mb_buffer(&mut self) {
let text = self
.minibuffer
.as_ref()
.map_or_else(String::new, |mb| mb.candidates.join("\n"));
self.mb_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.mb_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// Dropdown geometry `(left, top_y, width)` when the minibuffer has
/// candidates: a list anchored just above the bottom band, growing
/// upward, as wide as the widest candidate (clamped). `None` when
/// closed or candidate-free. `refresh_mb_buffer` must have run so the
/// width measurement is current.
fn mb_dropdown_rect(&self) -> Option<(f32, f32, f32)> {
let mb = self.minibuffer.as_ref()?;
let n = mb.candidates.len();
if n == 0 {
return None;
}
let widest = self
.mb_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH);
let band_top = text_area_bottom(self.config.height);
let top_y = band_top - n as f32 * MB_DROP_ROW_HEIGHT;
Some((STATUS_TEXT_PAD, top_y, width))
}
/// Minibuffer dropdown background + selection-highlight quads (Q#MB1).
/// Empty when closed / candidate-free.
fn mb_dropdown_vertex_bytes(&self) -> Vec<u8> {
let Some(mb) = self.minibuffer.as_ref() else {
return Vec::new();
};
let Some((x, top_y, width)) = self.mb_dropdown_rect() else {
return Vec::new();
};
let n = mb.candidates.len();
let mut rects = vec![MinimapRect {
x,
y: top_y,
w: width,
h: n as f32 * MB_DROP_ROW_HEIGHT,
color: MENU_BG,
}];
if let Some(sel) = mb.selected {
rects.push(MinimapRect {
x,
y: top_y + sel as f32 * MB_DROP_ROW_HEIGHT,
w: width,
h: MB_DROP_ROW_HEIGHT,
color: MENU_SELECTED_BG,
});
}
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Bookkeeping for an outgoing Pointer event: it supersedes any
/// unconfirmed optimistic-cursor prediction (the daemon's answer
/// will be the click position, not the typing prediction), and
@ -3392,6 +3562,21 @@ impl State {
&menu_vertices,
)
.cloned();
// Q#MB1 — the minibuffer dropdown quads (bg + selection), a top
// layer above the band. `refresh_mb_buffer` first so the width
// measurement in `mb_dropdown_vertex_bytes` is current.
self.refresh_mb_buffer();
let mb_vertices = self.mb_dropdown_vertex_bytes();
let mb_vertex_count = (mb_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let mb_bg_buffer = self
.mb_bg_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu minibuffer dropdown",
&mb_vertices,
)
.cloned();
// The band's strip rides the bg quad batch so it draws under
// the band text (text renders after the first quad draw).
let mut bg_vertices = self.decoration_background_vertex_bytes();
@ -3570,6 +3755,37 @@ impl State {
)
.expect("menu text_renderer prepare");
// Q#MB1 — prepare the minibuffer dropdown glyphs in their layer.
let mb_areas: Vec<TextArea> = self
.mb_dropdown_rect()
.map(|(x, top_y, width)| TextArea {
buffer: &self.mb_buffer,
left: x + MB_DROP_PAD_X,
top: top_y,
scale: 1.0,
bounds: TextBounds {
left: x as i32,
top: top_y as i32,
right: (x + width).round() as i32,
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
custom_glyphs: &[],
})
.into_iter()
.collect();
self.mb_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
mb_areas,
&mut self.swash_cache,
)
.expect("minibuffer text_renderer prepare");
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
@ -3619,6 +3835,15 @@ impl State {
self.quad_renderer
.render(&mut pass, vertex_buffer, minimap_vertex_count);
}
// Q#MB1 — the minibuffer dropdown draws above the band: bg +
// selection quads, then its candidate glyphs on top.
if let Some(vertex_buffer) = mb_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, mb_vertex_count);
}
self.mb_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("minibuffer text_renderer render");
// Q#CM1 — the context menu draws last: its bg/highlight quads
// occlude everything beneath, then its glyphs on top.
if let Some(vertex_buffer) = menu_bg_buffer.as_ref() {
@ -3816,6 +4041,14 @@ impl State {
/// Empty when no own cursor is known, it's in another buffer, or it
/// is scrolled out of the visible slice.
fn caret_vertex_bytes(&self) -> Vec<u8> {
// Q#MB1 — while the minibuffer is open the caret lives in the
// band at the input cursor, not in the buffer.
if self.minibuffer.is_some() {
return self
.minibuffer_caret_rect()
.map(|r| rects_to_vertex_bytes(&[r], self.config.width, self.config.height))
.unwrap_or_default();
}
let (vstart, vend) = self.view_range;
if vend <= vstart {
return Vec::new();
@ -3828,6 +4061,36 @@ impl State {
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
}
/// The caret rectangle for an open minibuffer (Q#MB1): a thin bar in
/// the bottom band at the input cursor. The band font is monospace,
/// so the per-char advance is the shaped status-left width divided by
/// its char count; the caret sits `prompt_chars + cursor` advances
/// from the band's left pad.
fn minibuffer_caret_rect(&self) -> Option<MinimapRect> {
let mb = self.minibuffer.as_ref()?;
let line_w = self
.status_left_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let chars = mb.prompt.chars().count() + mb.input.chars().count();
let advance = if chars > 0 {
line_w / chars as f32
} else {
0.0
};
let cursor_chars = mb.prompt.chars().count() as f32 + mb.cursor as f32;
let status_top =
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
Some(MinimapRect {
x: STATUS_TEXT_PAD + advance * cursor_chars,
y: status_top,
w: CARET_WIDTH,
h: STATUS_LINE_HEIGHT,
color: CARET_COLOR,
})
}
/// The caret rectangle for the own cursor, in slice coordinates: a
/// thin bar at the left edge of the glyph the cursor sits before (or
/// the right edge of the last glyph at line end). `None` when the
@ -4413,6 +4676,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::StatusFacts { .. } => "StatusFacts",
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
@ -4547,12 +4811,23 @@ fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool {
fn is_clipboard_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!(
(key, mods),
(ProtocolKey::Char('w'), Modifiers::ALT)
| (ProtocolKey::Char('w'), Modifiers::CTRL)
(ProtocolKey::Char('w'), Modifiers::ALT | Modifiers::CTRL)
| (ProtocolKey::Char('y'), Modifiers::CTRL)
)
}
/// The chords that open a minibuffer prompt or enter a prefix (Q#MB1):
/// `M-x` (execute-command) and the `C-x` prefix. Forwarded though
/// otherwise withheld; once the daemon enters the minibuffer / a pending
/// prefix, `dispatch_idle` goes false and the intercept gate round-trips
/// the rest. General Emacs-chord forwarding stays a separate thread.
fn is_minibuffer_open_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!(
(key, mods),
(ProtocolKey::Char('x'), Modifiers::ALT | Modifiers::CTRL)
)
}
fn is_plain_text_modifiers(mods: Modifiers) -> bool {
!mods.contains(Modifiers::CTRL)
&& !mods.contains(Modifiers::ALT)

View File

@ -883,6 +883,28 @@ pub enum InstanceMessage {
/// closed.
active: Option<u32>,
},
/// Minibuffer prompt state for a semantic frontend (Q#MB1, protocol
/// v12). The minibuffer is a single *global* core instance, so this
/// is bufferless; the producer still emits it from the active-buffer
/// viewport. `prompt: None` clears the GUI. Cached-compare
/// suppressed like `SearchPrompt`; daemon-gated `>= 12`.
MinibufferPrompt {
/// The prompt string (e.g. `"M-x "`), or `None` when no
/// minibuffer is open.
prompt: Option<String>,
/// The text typed so far.
input: String,
/// Codepoints before the cursor within `input` (the caret
/// position).
cursor: u32,
/// A windowed slice of the completion candidates (best-first,
/// already filtered/sorted by the core), `<= MB_VISIBLE`.
candidates: Vec<String>,
/// Highlighted row *within* `candidates`, or `None`.
selected: Option<u32>,
/// Total candidate count (the window is a slice of this).
total: u32,
},
}
/// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]).
@ -1158,7 +1180,7 @@ pub enum ResourceBody {
/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer
/// negotiates v9 and simply receives no `SearchPrompt` (the decorations
/// still highlight), rather than mis-decoding the wider shape.
pub const PROTOCOL_VERSION: u32 = 11;
pub const PROTOCOL_VERSION: u32 = 12;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1211,7 +1233,11 @@ pub const PROTOCOL_VERSION: u32 = 11;
/// `PointerKind::Context` (frontend-gated like `Pointer`/`TripleDown`),
/// `FrontendEvent::MenuPointer`, and `InstanceMessage::MenuPrompt`
/// (daemon-gated per session) — all additive, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11];
///
/// Q#MB1: extended to `[6, 7, 8, 9, 10, 11, 12]`.
/// `InstanceMessage::MinibufferPrompt` is additive and daemon-gated per
/// session, so the ladder resumes again.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1047,6 +1047,9 @@ fn dispatcher_loop(
let peer_knows_menu_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 11);
let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 12);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1065,6 +1068,13 @@ fn dispatcher_loop(
{
continue;
}
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
// simply can't render the GUI minibuffer.
if !peer_knows_minibuffer_prompt
&& matches!(msg, InstanceMessage::MinibufferPrompt { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//

View File

@ -401,6 +401,10 @@ impl Frontend {
// the TUI renders the menu via its cell overlay instead, so
// it drops this silently like the other semantic families.
| InstanceMessage::MenuPrompt { .. }
// Q#MB1 — MinibufferPrompt is the semantic-frontend minibuffer
// surface; the TUI paints the minibuffer via its own bottom
// row, so it drops this silently too.
| InstanceMessage::MinibufferPrompt { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_eleven_for_context_menu() {
fn protocol_version_is_twelve_for_minibuffer() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1699,8 +1699,10 @@ mod tests {
// Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid;
// encoding change to that variant, still daemon-gated). Q#CM1
// bumped 10→11 (`PointerKind::Context` + `MenuPointer` +
// `MenuPrompt`, all additive; the message daemon-gated).
assert_eq!(PROTOCOL_VERSION, 11);
// `MenuPrompt`, all additive; the message daemon-gated). Q#MB1
// bumped 11→12 (`InstanceMessage::MinibufferPrompt`, additive +
// daemon-gated).
assert_eq!(PROTOCOL_VERSION, 12);
}
#[test]
@ -1709,20 +1711,21 @@ mod tests {
// every cell-carrying message, ending the v1v5 ladder —
// pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1: the ladder resumes above
// that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 + v10
// (`SearchPrompt` + regex/invalid), v11 (the context menu) all
// interoperate, so v6 through v11 talk.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1: the ladder resumes
// above that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 +
// v10 (`SearchPrompt` + regex/invalid), v11 (the context menu),
// v12 (the GUI minibuffer) all interoperate, so v6 through v12 talk.
assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8));
assert!(is_supported_protocol_version(9));
assert!(is_supported_protocol_version(10));
assert!(is_supported_protocol_version(11));
for rejected in [0, 1, 2, 3, 4, 5, 12, u32::MAX] {
assert!(is_supported_protocol_version(12));
for rejected in [0, 1, 2, 3, 4, 5, 13, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v11 binary"
"v{rejected} must be rejected by a v12 binary"
);
}
}
@ -1929,6 +1932,53 @@ mod tests {
}
}
#[test]
fn minibuffer_prompt_round_trips_through_postcard() {
// Q#MB1 — the v12 wire variant. Cover an open prompt with a
// windowed candidate list + selection, and a cleared band.
let cases = [
(
Some("M-x ".to_owned()),
"ed".to_owned(),
2u32,
vec!["edit.copy".to_owned(), "edit.cut".to_owned()],
Some(1u32),
7u32,
),
(None, String::new(), 0, Vec::new(), None, 0),
];
for (prompt, input, cursor, candidates, selected, total) in cases {
let msg = InstanceMessage::MinibufferPrompt {
prompt: prompt.clone(),
input: input.clone(),
cursor,
candidates: candidates.clone(),
selected,
total,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
match decoded {
InstanceMessage::MinibufferPrompt {
prompt: got_prompt,
input: got_input,
cursor: got_cursor,
candidates: got_candidates,
selected: got_selected,
total: got_total,
} => {
assert_eq!(got_prompt, prompt);
assert_eq!(got_input, input);
assert_eq!(got_cursor, cursor);
assert_eq!(got_candidates, candidates);
assert_eq!(got_selected, selected);
assert_eq!(got_total, total);
}
other => panic!("expected MinibufferPrompt, got {other:?}"),
}
}
}
#[test]
fn key_event_to_crossterm_round_trips() {
// Build a protocol KeyEvent, translate to crossterm, translate

View File

@ -86,6 +86,32 @@ type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
/// menu.
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// Cached `MinibufferPrompt` payload for cached-compare suppression
/// (Q#MB1): `(prompt, input, cursor, candidates-window, selected, total)`.
/// A `None` prompt means the minibuffer is closed.
type MinibufferFacts = (Option<String>, String, u32, Vec<String>, Option<u32>, u32);
/// How many completion candidates the minibuffer ships per frame — a
/// scrolled window around the selection, not the full (≤1024) list.
const MB_VISIBLE: usize = 10;
/// A window of up to [`MB_VISIBLE`] candidates around `selected`, plus
/// the selection's index *within* that window. Keeps the selected row
/// visible as the user cycles a long list.
fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<String>, Option<u32>) {
if candidates.is_empty() {
return (Vec::new(), None);
}
let sel = selected.unwrap_or(0).min(candidates.len() - 1);
let start = sel
.saturating_sub(MB_VISIBLE / 2)
.min(candidates.len().saturating_sub(MB_VISIBLE));
let end = (start + MB_VISIBLE).min(candidates.len());
let window = candidates[start..end].to_vec();
let selected_in_window = selected.map(|s| (s - start) as u32);
(window, selected_in_window)
}
/// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families.
@ -137,6 +163,10 @@ pub struct SemanticRenderState {
/// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for
/// cached-compare suppression (see [`MenuPromptFacts`]).
last_menu_prompt: HashMap<BufferId, MenuPromptFacts>,
/// Last emitted `MinibufferPrompt` payload (Q#MB1) — a single value,
/// not per-buffer, because the minibuffer is one global core
/// instance.
last_minibuffer: Option<MinibufferFacts>,
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
/// the tree-sitter highlights query over the *whole declared
/// viewport* (which the GPU frontend sets to the entire buffer)
@ -207,6 +237,7 @@ impl SemanticRenderState {
last_adornments: HashMap::new(),
last_search_prompt: HashMap::new(),
last_menu_prompt: HashMap::new(),
last_minibuffer: None,
last_summary: HashMap::new(),
last_status: HashMap::new(),
last_style_gate: HashMap::new(),
@ -411,6 +442,8 @@ impl SemanticRenderState {
out.extend(self.search_prompt_msg(state, vp.buffer_id));
// --- MenuPrompt (context menu; Q#CM1, protocol v11) ---
out.extend(self.menu_prompt_msg(state, vp.buffer_id));
// --- MinibufferPrompt (Q#MB1, protocol v12) ---
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
out
}
@ -534,6 +567,65 @@ impl SemanticRenderState {
Some(msg)
}
/// The `MinibufferPrompt` message for this frame, or `None` when the
/// (global) minibuffer state is unchanged (Q#MB1). Emitted only from
/// the active buffer's viewport so the bufferless message ships once
/// per frame. Closed = `prompt: None`; first sight while closed stays
/// silent. The daemon keeps the variant off wires negotiated `< 12`.
fn minibuffer_prompt_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let facts: MinibufferFacts = {
let core = state.core.borrow();
if buffer_id != core.active_buffer_id() {
return None;
}
let mb = &core.minibuffer;
match mb.session.as_ref() {
Some(session) => {
let input = mb.contents();
let cursor_byte = mb.cursor as usize;
let cursor = input
.char_indices()
.take_while(|(i, _)| *i < cursor_byte)
.count() as u32;
let total = session.candidates.len() as u32;
let (candidates, selected) =
minibuffer_window(&session.candidates, session.selected);
(
Some(session.prompt.clone()),
input,
cursor,
candidates,
selected,
total,
)
}
None => (None, String::new(), 0, Vec::new(), None, 0),
}
};
if self.last_minibuffer.as_ref() == Some(&facts) {
return None;
}
// First sight while closed: nothing to clear, stay silent.
if self.last_minibuffer.is_none() && facts.0.is_none() {
self.last_minibuffer = Some(facts);
return None;
}
let msg = InstanceMessage::MinibufferPrompt {
prompt: facts.0.clone(),
input: facts.1.clone(),
cursor: facts.2,
candidates: facts.3.clone(),
selected: facts.4,
total: facts.5,
};
self.last_minibuffer = Some(facts);
Some(msg)
}
/// The `StatusFacts` message for this frame, or `None` when
/// nothing changed. Carries the facts a semantic frontend cannot
/// derive locally: buffer name, modified flag, whole-file
@ -3199,6 +3291,82 @@ mod tests {
);
}
#[test]
fn minibuffer_window_scrolls_to_keep_selection_visible() {
let cands: Vec<String> = (0..30).map(|i| format!("c{i}")).collect();
// No selection → top window, no highlight.
let (w, sel) = minibuffer_window(&cands, None);
assert_eq!(w.len(), MB_VISIBLE);
assert_eq!(w[0], "c0");
assert_eq!(sel, None);
// Deep selection scrolls; the selected row stays inside the window.
let (w, sel) = minibuffer_window(&cands, Some(20));
assert_eq!(w.len(), MB_VISIBLE);
assert_eq!(w[sel.unwrap() as usize], "c20");
// End selection clamps the window to the tail.
let (w, sel) = minibuffer_window(&cands, Some(29));
assert_eq!(w.last().unwrap(), "c29");
assert_eq!(w[sel.unwrap() as usize], "c29");
// Short list passes through with the selection intact.
let short: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
assert_eq!(minibuffer_window(&short, Some(2)), (short.clone(), Some(2)));
// Empty.
assert_eq!(minibuffer_window(&[], Some(0)), (Vec::new(), None));
}
fn minibuffer_prompt_of(
msgs: &[InstanceMessage],
) -> Option<(Option<String>, String, Vec<String>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::MinibufferPrompt {
prompt,
input,
candidates,
..
} => Some((prompt.clone(), input.clone(), candidates.clone())),
_ => None,
})
}
#[test]
fn minibuffer_prompt_emits_prompt_input_and_windowed_candidates() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
// No minibuffer: the producer stays silent on first sight.
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
// Open an `M-x` prompt (command completion) via the Lua API.
state
.lua_host
.lua()
.load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }")
.exec()
.expect("open minibuffer");
let (prompt, input, cands) =
minibuffer_prompt_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
assert_eq!(prompt.as_deref(), Some("M-x "));
assert_eq!(input, "");
// Empty input matches every command; the wire carries a window.
assert!(!cands.is_empty(), "M-x seeds command candidates");
assert!(cands.len() <= MB_VISIBLE, "candidates ship windowed");
// Unchanged → suppressed (cached-compare).
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
// Cancel: the prompt clears (None).
state
.lua_host
.lua()
.load("pmacs.minibuffer.cancel()")
.exec()
.expect("cancel");
let (prompt, _, _) = minibuffer_prompt_of(&s.render_frame(&state)).expect("clear emitted");
assert!(prompt.is_none(), "cancel clears the minibuffer band");
}
#[test]
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
let state = empty_state();