1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
//! Browseable cheatsheet pane (NvCheatsheet analogue). Walks the live
//! `Keymap` + command registry and renders one row per `chord → command`
//! grouped by `Command::group`. `/` filters; ↑/↓ navigate; the pane is
//! read-only. Integration to `:Maps`/`:Keys` (which toasts).
use std::collections::BTreeMap;
/// One row in the cheatsheet — a single chord binding.
#[derive(Debug, Clone)]
pub struct CheatsheetRow {
pub chord: String,
pub command_id: String,
pub title: String,
}
/// One section in the cheatsheet — every chord whose target command shares
/// this group label.
#[derive(Debug, Clone)]
pub struct CheatsheetSection {
pub group: String,
pub rows: Vec<CheatsheetRow>,
}
#[derive(Debug, Clone, Default)]
pub struct CheatsheetPane {
pub sections: Vec<CheatsheetSection>,
/// Cursor row in the *flattened* row list (headers excluded — there's
/// no useful action for selecting a header).
pub selected: usize,
pub scroll: usize,
/// `/`-filter narrowing.
pub query: String,
pub filter_mode: bool,
/// Group labels currently collapsed. When a group is collapsed
/// its rows don't render and don't count toward `selected` /
/// flattened-row math. `z` toggles the current row's group;
/// `Z` collapses everything.
pub collapsed: std::collections::HashSet<String>,
}
impl CheatsheetPane {
/// Build a fresh cheatsheet from the active keymap + command registry.
/// Sections are populated from chord bindings first; a trailing
/// "(unbound)" group lists every registered command WITHOUT a
/// chord so the pane functions as a discoverable command catalog
/// (not just a chord reference).
pub fn build(keymap: &crate::input::keymap::Keymap) -> Self {
let reg = crate::command::registry();
let mut grouped: BTreeMap<String, Vec<CheatsheetRow>> = BTreeMap::new();
let mut bound_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (seq, id) in keymap.iter() {
bound_ids.insert(id);
let (group, title) = match reg.get(id) {
Some(c) => (c.group.to_string(), c.title.to_string()),
None => ("(unknown)".to_string(), id.to_string()),
};
grouped.entry(group).or_default().push(CheatsheetRow {
chord: crate::input::keymap::chord_seq_to_spec(seq),
command_id: id.to_string(),
title,
});
}
// 2026-06-21 multilang SEV-3: also add leader-chord bindings
// from the whichkey trie. Was: leader chords (`<leader>Lct`
// = `cargo.test` etc.) were dispatched by `whichkey.rs`
// separately from the `Keymap` and didn't appear in
// `keymap.iter()`, so all leader-only commands fell into
// `(unbound)` — bloating it AND lying to the user about what
// they could chord-reach.
for (chord_path, id) in crate::whichkey::enumerate_leaves() {
// String id-owned: stash in bound_ids via a leaked &'static
// is overkill; track owned in a parallel set.
if reg.get(id).is_some() {
let owned: &'static str = id;
bound_ids.insert(owned);
}
let (group, title) = match reg.get(id) {
Some(c) => (c.group.to_string(), c.title.to_string()),
None => continue,
};
grouped.entry(group).or_default().push(CheatsheetRow {
chord: chord_path,
command_id: id.to_string(),
title,
});
}
let mut sections: Vec<CheatsheetSection> = grouped
.into_iter()
.map(|(group, mut rows)| {
rows.sort_by(|a, b| a.chord.cmp(&b.chord));
CheatsheetSection { group, rows }
})
.collect();
// Unbound section — every registered command not in the keymap.
// 2026-06-20 — cheatsheet now doubles as a discoverable command
// catalog (~300+ palette commands; many lack chords).
let unbound: Vec<CheatsheetRow> = reg
.all()
.iter()
.filter(|c| !bound_ids.contains(c.id))
.map(|c| CheatsheetRow {
chord: "·".to_string(),
command_id: c.id.to_string(),
title: c.title.to_string(),
})
.collect();
if !unbound.is_empty() {
let mut rows = unbound;
rows.sort_by(|a, b| a.command_id.cmp(&b.command_id));
sections.push(CheatsheetSection {
group: "(unbound)".to_string(),
rows,
});
}
// design-critic end-of-day 2026-06-28 #6: vim INSERT-mode
// Ctrl chords are wired in src/input/vim.rs::handle_insert,
// not as registered Commands — so they were invisible to the
// cheatsheet (which builds from keymap.iter() +
// command::registry()). The 11 stripped chords from
// input/keymap.rs:207 expand to 11 rows (Ctrl+R counts once
// in the strip list, twice here because it pastes by which
// register-name follows). Ctrl+G is intentionally NOT
// listed — it has no INSERT-mode arm yet (input-reviewer
// W-1 2026-06-28 flagged the prior `(future <C-G>u)` entry
// as misleading).
let vim_insert_rows = vec![
("Ctrl+H", "INSERT-backspace (delete prev char)"),
("Ctrl+W", "INSERT-delete previous word"),
("Ctrl+U", "INSERT-delete to line start"),
("Ctrl+J", "INSERT-insert newline"),
("Ctrl+T", "INSERT-indent line"),
("Ctrl+D", "INSERT-outdent line"),
("Ctrl+N", "INSERT-keyword completion next"),
("Ctrl+R \"", "INSERT-paste unnamed register"),
("Ctrl+R a..z", "INSERT-paste named register"),
("Ctrl+Y", "INSERT-copy char from line above"),
("Ctrl+E", "INSERT-copy char from line below"),
];
let rows: Vec<CheatsheetRow> = vim_insert_rows
.into_iter()
.map(|(chord, title)| CheatsheetRow {
chord: chord.to_string(),
command_id: "(vim insert handler)".to_string(),
title: title.to_string(),
})
.collect();
sections.push(CheatsheetSection {
group: "vim insert".to_string(),
rows,
});
CheatsheetPane {
sections,
selected: 0,
scroll: 0,
query: String::new(),
filter_mode: false,
collapsed: std::collections::HashSet::new(),
}
}
/// Total number of selectable (non-header) rows in the current filtered
/// view. Used by the mouse click handler to clamp `selected`.
pub fn visible_rows_len(&self) -> usize {
self.visible_sections().iter().map(|s| s.rows.len()).sum()
}
/// Return the sections filtered by the current `/` query. Sections with
/// no matching rows are omitted entirely; rows inside a kept section
/// match against chord OR id OR title (case-insensitive substring).
pub fn visible_sections(&self) -> Vec<CheatsheetSection> {
// 2026-06-21 lsp-cheat-test SEV-2: was checking collapsed
// BEFORE applying the filter, so `/save` couldn't surface
// matches that lived inside a collapsed section AND
// collapsed headers persisted with zero hits. Now: when a
// text filter is active, ignore collapse — the user is
// searching and they want everything in scope.
let q = self.query.to_lowercase();
let filter_active = !q.is_empty();
self.sections
.iter()
.filter_map(|sec| {
// Collapsed sections (no active filter) keep their
// header but contribute zero rows.
if !filter_active && self.collapsed.contains(&sec.group) {
return Some(CheatsheetSection {
group: sec.group.clone(),
rows: Vec::new(),
});
}
let rows: Vec<_> = if !filter_active {
sec.rows.clone()
} else {
sec.rows
.iter()
.filter(|r| {
r.chord.to_lowercase().contains(&q)
|| r.command_id.to_lowercase().contains(&q)
|| r.title.to_lowercase().contains(&q)
})
.cloned()
.collect()
};
if rows.is_empty() && filter_active {
None
} else {
Some(CheatsheetSection {
group: sec.group.clone(),
rows,
})
}
})
.collect()
}
/// Group of the currently-selected row, derived by walking
/// flattened rows. Used by `z` to toggle the right section.
pub fn selected_group(&self) -> Option<String> {
let mut idx = 0usize;
for sec in self.visible_sections() {
if sec.rows.is_empty() {
continue;
}
if self.selected < idx + sec.rows.len() {
return Some(sec.group);
}
idx += sec.rows.len();
}
None
}
/// Toggle the focused row's section in the collapsed set.
/// 2026-06-21 lsp-cheat-test SEV-3 cheatsheet-Z-resets-selection:
/// was dropping the user back to the top on every z / Z.
/// Now clamps `selected` to the new visible-row count instead.
pub fn toggle_collapsed_at_selection(&mut self) {
if let Some(group) = self.selected_group() {
if self.collapsed.contains(&group) {
self.collapsed.remove(&group);
} else {
self.collapsed.insert(group);
}
self.clamp_selection();
}
}
/// Collapse every section. `Z` chord.
pub fn collapse_all(&mut self) {
self.collapsed = self.sections.iter().map(|s| s.group.clone()).collect();
self.clamp_selection();
}
/// Expand every section.
pub fn expand_all(&mut self) {
self.collapsed.clear();
self.clamp_selection();
}
/// Re-clamp `selected` after the visible-row count changes
/// (collapse / expand toggles). Preserves position when
/// possible; falls back to the last valid row.
fn clamp_selection(&mut self) {
let n = self.visible_row_count();
if n == 0 {
self.selected = 0;
self.scroll = 0;
return;
}
if self.selected >= n {
self.selected = n - 1;
}
}
/// Count of selectable (non-header) rows across the visible sections.
pub fn visible_row_count(&self) -> usize {
self.visible_sections().iter().map(|s| s.rows.len()).sum()
}
pub fn move_down(&mut self) {
let n = self.visible_row_count();
if n == 0 {
return;
}
self.selected = (self.selected + 1).min(n - 1);
}
pub fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn page_down(&mut self, page: usize) {
let n = self.visible_row_count();
if n == 0 {
return;
}
self.selected = (self.selected + page).min(n - 1);
}
pub fn page_up(&mut self, page: usize) {
self.selected = self.selected.saturating_sub(page);
}
pub fn jump_top(&mut self) {
self.selected = 0;
self.scroll = 0;
}
pub fn jump_bottom(&mut self) {
let n = self.visible_row_count();
if n > 0 {
self.selected = n - 1;
}
}
/// The `command_id` at the currently-selected row, if any.
pub fn selected_command_id(&self) -> Option<String> {
let mut i = 0usize;
for sec in self.visible_sections() {
for row in sec.rows {
if i == self.selected {
return Some(row.command_id);
}
i += 1;
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn cheatsheet_has_at_least_one_section() {
let km = crate::input::keymap::Keymap::build(&Config::default());
let cs = CheatsheetPane::build(&km);
assert!(
!cs.sections.is_empty(),
"expected at least one cheatsheet section"
);
}
#[test]
fn unbound_section_lists_palette_only_commands() {
let km = crate::input::keymap::Keymap::build(&Config::default());
let cs = CheatsheetPane::build(&km);
// The "(unbound)" section must exist (mnml ships hundreds of
// palette-only commands).
let sec = cs
.sections
.iter()
.find(|s| s.group == "(unbound)")
.expect("expected an (unbound) section in the cheatsheet");
assert!(!sec.rows.is_empty(), "(unbound) section is empty");
// Spot-check a couple of palette-only commands (no default chord).
let ids: std::collections::HashSet<&str> =
sec.rows.iter().map(|r| r.command_id.as_str()).collect();
assert!(
ids.contains("http.history_global"),
":http.history_global should appear in (unbound)"
);
assert!(
ids.contains("http.ai_build"),
":http.ai_build should appear in (unbound)"
);
}
#[test]
fn cheatsheet_filter_narrows_by_chord_or_id_or_title() {
let km = crate::input::keymap::Keymap::build(&Config::default());
let mut cs = CheatsheetPane::build(&km);
cs.query = "save".to_string();
let v = cs.visible_sections();
// At least one row whose id or title contains "save".
assert!(
v.iter()
.flat_map(|s| &s.rows)
.any(|r| r.command_id.to_lowercase().contains("save")
|| r.title.to_lowercase().contains("save")),
"expected at least one row matching 'save'"
);
}
#[test]
fn cheatsheet_selected_command_id_walks_visible_rows() {
let km = crate::input::keymap::Keymap::build(&Config::default());
let cs = CheatsheetPane::build(&km);
if cs.visible_row_count() > 0 {
assert!(cs.selected_command_id().is_some());
}
}
}