···11+use crate::ast::{CommandDef, FlagDef};
22+use std::process::Command;
33+44+fn run_carapace_cmd(args: &[&str]) -> Result<String, String> {
55+ let mut cmd = Command::new("carapace");
66+ for a in args {
77+ cmd.arg(a);
88+ }
99+ let out = cmd
1010+ .output()
1111+ .map_err(|e| format!("carapace {args:?} failed to run: {e}"))?;
1212+ if !out.status.success() {
1313+ let stderr = String::from_utf8_lossy(&out.stderr).to_string();
1414+ return Err(format!("carapace {:?} failed: {}", args, stderr.trim()));
1515+ }
1616+ Ok(String::from_utf8_lossy(&out.stdout).to_string())
1717+}
1818+1919+pub fn list() -> Result<Vec<String>, String> {
2020+ let s = run_carapace_cmd(&["--list"])?;
2121+ Ok(s.lines()
2222+ .map(|l| l.trim())
2323+ .filter(|l| !l.is_empty())
2424+ .filter_map(|l| l.split_whitespace().next())
2525+ .filter(|name| which::which(name).is_ok())
2626+ .map(|s| s.to_string())
2727+ .collect())
2828+}
2929+3030+pub fn list_with_desc() -> Result<Vec<(String, String)>, String> {
3131+ let s = run_carapace_cmd(&["--list"])?;
3232+ let out: Vec<(String, String)> = s
3333+ .lines()
3434+ .map(|l| l.trim())
3535+ .filter(|l| !l.is_empty())
3636+ .filter_map(|line| {
3737+ line.split_whitespace()
3838+ .next()
3939+ .and_then(|name| {
4040+ if which::which(name).is_ok() {
4141+ let short = if line.len() > name.len() {
4242+ line[name.len()..].trim().to_string()
4343+ } else {
4444+ String::new()
4545+ };
4646+ Some((name.to_string(), short))
4747+ } else {
4848+ None
4949+ }
5050+ })
5151+ })
5252+ .collect();
5353+ Ok(out)
5454+}
5555+5656+pub fn export(cmd_name: &str) -> Result<CommandDef, String> {
5757+ if cmd_name.trim().is_empty() {
5858+ return Err("empty command name".to_string());
5959+ }
6060+ let s = run_carapace_cmd(&[cmd_name, "export"])?;
6161+6262+ let r: serde_json::Value = serde_json::from_str(&s)
6363+ .map_err(|e| format!("failed to parse carapace export JSON: {e}"))?;
6464+6565+ fn map_raw(r: &serde_json::Value) -> CommandDef {
6666+ let name = r
6767+ .get("Name")
6868+ .and_then(|v| v.as_str())
6969+ .unwrap_or("")
7070+ .to_string();
7171+ let short = r
7272+ .get("Short")
7373+ .and_then(|v| v.as_str())
7474+ .unwrap_or("")
7575+ .to_string();
7676+ let aliases = r
7777+ .get("Aliases")
7878+ .and_then(|v| v.as_array())
7979+ .map(|arr| {
8080+ arr.iter()
8181+ .filter_map(|x| x.as_str().map(|s| s.to_string()))
8282+ .collect()
8383+ })
8484+ .unwrap_or_default();
8585+ let mut flags = Vec::new();
8686+ if let Some(local) = r.get("LocalFlags").and_then(|v| v.as_array()) {
8787+ for f in local {
8888+ let long = f
8989+ .get("Longhand")
9090+ .and_then(|v| v.as_str())
9191+ .unwrap_or("")
9292+ .to_string();
9393+ let shortf = f
9494+ .get("Shorthand")
9595+ .and_then(|v| v.as_str())
9696+ .unwrap_or("")
9797+ .to_string();
9898+ let usage = f
9999+ .get("Usage")
100100+ .and_then(|v| v.as_str())
101101+ .unwrap_or("")
102102+ .to_string();
103103+ let typ = f.get("Type").and_then(|v| v.as_str()).unwrap_or("bool");
104104+ let fd = FlagDef {
105105+ longhand: long,
106106+ shorthand: shortf,
107107+ usage,
108108+ requires_value: typ != "bool",
109109+ };
110110+ flags.push(fd);
111111+ }
112112+ }
113113+ let mut subs = Vec::new();
114114+ if let Some(cmds) = r.get("Commands").and_then(|v| v.as_array()) {
115115+ for c in cmds {
116116+ subs.push(map_raw(c));
117117+ }
118118+ }
119119+ CommandDef {
120120+ name,
121121+ short,
122122+ aliases,
123123+ flags,
124124+ subcommands: subs,
125125+ }
126126+ }
127127+128128+ Ok(map_raw(&r))
129129+}
+18
crates/src/lib.rs
···11+//! van - interactive command completion preview tool
22+//!
33+//! Library crate exposing the small components used by the binary.
44+//!
55+//! Tests live close to the modules they exercise as unit tests.
66+77+pub mod acekey;
88+pub mod ast;
99+pub mod carapace;
1010+1111+pub mod ui;
1212+1313+// Keep crate root minimal; tests moved into module files.
1414+1515+#[cfg(test)]
1616+mod _root_tests {
1717+ // intentionally empty
1818+}
+522
crates/src/main.rs
···11+// Entry point: program main
22+// Handles --hook, --exe, --help, and runs the TUI
33+//
44+// TUI Docs: https://github.com/whit3rabbit/bubbletea-rs look for related crates there and examples on each of them.
55+66+use std::env;
77+use std::fs;
88+use std::path::Path;
99+use std::process::{self, Command, Stdio};
1010+use van::ui::{Model as UiModel, initial_model, run as noninteractive_run};
1111+1212+use bubbletea_rs::{
1313+ Program, event::KeyMsg, event::WindowSizeMsg, model::Model as TeaModel, window_size,
1414+};
1515+use crossterm::event::{KeyCode, KeyModifiers};
1616+1717+// Adapter type implementing bubbletea-rs Model trait by delegating to our UiModel
1818+struct TeaAdapter {
1919+ inner: UiModel,
2020+}
2121+2222+impl TeaModel for TeaAdapter {
2323+ fn init() -> (Self, Option<bubbletea_rs::command::Cmd>) {
2424+ // preload carapace --list with descriptions so interactive UI shows top-level commands immediately
2525+ let entries = van::carapace::list_with_desc().unwrap_or_default();
2626+ let mut adapter = TeaAdapter {
2727+ inner: initial_model(entries),
2828+ };
2929+ let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
3030+ adapter.inner.update(van::ui::Msg::WindowSize {
3131+ width: width as usize,
3232+ height: height as usize,
3333+ });
3434+ let cmd = window_size();
3535+ (adapter, Some(cmd))
3636+ }
3737+3838+ fn update(&mut self, msg: bubbletea_rs::event::Msg) -> Option<bubbletea_rs::command::Cmd> {
3939+ // Map bubbletea-rs Msg types to our ui::Msg and call update
4040+ if let Some(km) = msg.downcast_ref::<KeyMsg>() {
4141+ // Structured handling using crossterm types (KeyCode, KeyModifiers)
4242+ match &km.key {
4343+ KeyCode::Enter => {
4444+ // Enter -> perform ExecProcess semantics
4545+ self.inner.update(van::ui::Msg::KeyEnter);
4646+ let preview = &self.inner.exit_preview;
4747+ if preview.is_empty() {
4848+ return None;
4949+ }
5050+ let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
5151+ let mut cmd = Command::new(shell);
5252+ cmd.arg("-c")
5353+ .arg(preview)
5454+ .stdin(Stdio::inherit())
5555+ .stdout(Stdio::inherit())
5656+ .stderr(Stdio::inherit());
5757+ match cmd.status() {
5858+ Ok(status) => {
5959+ if let Some(code) = status.code() {
6060+ process::exit(code);
6161+ } else {
6262+ process::exit(0);
6363+ }
6464+ }
6565+ Err(e) => {
6666+ eprintln!("failed to execute command: {e}");
6767+ process::exit(1);
6868+ }
6969+ }
7070+ }
7171+ KeyCode::Backspace => {
7272+ self.inner.update(van::ui::Msg::KeyBackspace);
7373+ }
7474+ KeyCode::Esc => {
7575+ // Quit immediately unless we're in value-input mode
7676+ if !self.inner.in_value_mode {
7777+ return Some(bubbletea_rs::quit());
7878+ }
7979+ self.inner.update(van::ui::Msg::KeyEsc);
8080+ }
8181+ KeyCode::Up => {
8282+ self.inner.update(van::ui::Msg::KeyUp);
8383+ }
8484+ KeyCode::Down => {
8585+ self.inner.update(van::ui::Msg::KeyDown);
8686+ }
8787+ KeyCode::Char(ch) => {
8888+ // Control-key handling
8989+ if km.modifiers.contains(KeyModifiers::CONTROL) {
9090+ match ch {
9191+ 'n' | 'N' => {
9292+ self.inner.update(van::ui::Msg::KeyDown);
9393+ }
9494+ 'p' | 'P' => {
9595+ self.inner.update(van::ui::Msg::KeyUp);
9696+ }
9797+ 'c' | 'C' => {
9898+ return Some(bubbletea_rs::quit());
9999+ }
100100+ _ => {}
101101+ }
102102+ } else if *ch == ' ' {
103103+ self.inner.update(van::ui::Msg::KeySpace);
104104+ } else {
105105+ self.inner.update(van::ui::Msg::Rune(*ch));
106106+ }
107107+ }
108108+ _ => { /* ignore other keys */ }
109109+ }
110110+111111+ return None;
112112+ }
113113+ if let Some(ws) = msg.downcast_ref::<WindowSizeMsg>() {
114114+ self.inner.update(van::ui::Msg::WindowSize {
115115+ width: ws.width as usize,
116116+ height: ws.height as usize,
117117+ });
118118+ return None;
119119+ }
120120+ None
121121+ }
122122+123123+ fn view(&self) -> String {
124124+ // delegate to UiModel's styled renderer
125125+ self.inner.render_full()
126126+ }
127127+}
128128+129129+fn print_help() {
130130+ println!("van - interactive command completion preview tool");
131131+ println!();
132132+ println!("Usage:");
133133+ println!(" van [<command> [args...]]");
134134+ println!();
135135+ println!("Options:");
136136+ println!(
137137+ " --hook <shell> Output shell hook code for <shell>. Supported: bash, zsh, fish, nushell. If <shell> omitted, auto-detects from $SHELL and falls back to bash."
138138+ );
139139+ println!(
140140+ " --exe <cmd> Optional: override the executable string to embed in the hook (e.g. './target/debug/van')."
141141+ );
142142+ println!(" --help Show this help message.");
143143+ println!();
144144+ println!("Description:");
145145+ println!(
146146+ " When the hook is installed in your shell, your shell will invoke \"<exe> <command line>\" to produce completion candidates for the currently typed command line. For example, if you type 'jj commit' and press TAB, the shell will call '<exe> jj commit' to obtain completion items."
147147+ );
148148+ println!();
149149+ println!("Installation example (bash):");
150150+ println!(" van --hook bash > ~/.van_hook.sh");
151151+ println!(" source ~/.van_hook.sh");
152152+}
153153+154154+// shell_single_quote safely single-quotes s for embedding in POSIX shells.
155155+fn shell_single_quote(s: &str) -> String {
156156+ if s.is_empty() {
157157+ return "''".to_string();
158158+ }
159159+ let escaped = s.replace('\'', "'\\''");
160160+ format!("'{escaped}'")
161161+}
162162+163163+// parse_run_from_parts tries to find a '<exe> run' invocation in parts and reconstruct the run command string
164164+fn parse_run_from_parts(parts: &[String]) -> Option<String> {
165165+ // look for a pair where the second token is "run" and then collect valid run args after it
166166+ parts.windows(2).enumerate().find_map(|(i, pair)| {
167167+ if pair[1] != "run" {
168168+ return None;
169169+ }
170170+ let run_args: Vec<String> = parts
171171+ .iter()
172172+ .skip(i + 2)
173173+ .take_while(|t| {
174174+ if t.is_empty() || t.starts_with('-') {
175175+ return false;
176176+ }
177177+ if Path::new(t).exists() {
178178+ return true;
179179+ }
180180+ // Treat explicit paths or relative paths as run arguments
181181+ if t.contains('/') || t.starts_with("./") || t.starts_with("../") {
182182+ return true;
183183+ }
184184+ false
185185+ })
186186+ .cloned()
187187+ .collect();
188188+189189+ if run_args.is_empty() {
190190+ None
191191+ } else {
192192+ Some(format!("run {}", run_args.join(" ")))
193193+ }
194194+ })
195195+}
196196+197197+// detect_exec_from_parent: attempts to determine the original executable string used to invoke this program.
198198+fn detect_exec_from_parent() -> String {
199199+ // default to argv[0]
200200+ let default_exe = env::args().next().unwrap_or_default();
201201+202202+ // try to determine parent pid via ps -p <pid> -o ppid=
203203+ let pid = process::id();
204204+ let ppid_out = Command::new("ps")
205205+ .arg("-p")
206206+ .arg(pid.to_string())
207207+ .arg("-o")
208208+ .arg("ppid=")
209209+ .output();
210210+ if let Ok(out) = ppid_out {
211211+ if out.status.success() {
212212+ if let Ok(ppid_str) = String::from_utf8(out.stdout) {
213213+ if let Ok(ppid) = ppid_str.trim().parse::<u32>() {
214214+ // platform-specific detection
215215+ if cfg!(target_os = "linux") {
216216+ // linux: try reading /proc/<ppid>/cmdline
217217+ let proc_cmd = format!("/proc/{ppid}/cmdline");
218218+ if let Ok(data) = fs::read(&proc_cmd) {
219219+ // cmdline is NUL-separated; convert bytes to UTF-8 and split on NULs
220220+ if let Ok(raw) = String::from_utf8(data) {
221221+ let parts: Vec<String> = raw
222222+ .split('\0')
223223+ .filter(|s| !s.is_empty())
224224+ .map(|s| s.to_string())
225225+ .collect();
226226+ if let Some(r) = parse_run_from_parts(&parts) {
227227+ return r;
228228+ }
229229+ }
230230+ }
231231+ // fallback: use ps -p <ppid> -o command=
232232+ if let Some(cmdline) = get_ps_command(ppid) {
233233+ let cmdline = cmdline.trim();
234234+ if let Some(idx) = cmdline.find("run ") {
235235+ let rest = cmdline[idx + "run ".len()..].trim();
236236+ if !rest.is_empty() {
237237+ return format!("run {rest}");
238238+ }
239239+ }
240240+ }
241241+ } else if cfg!(target_os = "macos") {
242242+ if let Some(cmdline) = get_ps_command(ppid) {
243243+ let cmdline = cmdline.trim();
244244+ if let Some(idx) = cmdline.find("run ") {
245245+ let rest = cmdline[idx + "run ".len()..].trim();
246246+ if !rest.is_empty() {
247247+ return format!("run {rest}");
248248+ }
249249+ }
250250+ }
251251+ } else if cfg!(target_os = "windows") {
252252+ // windows: query via PowerShell
253253+ let ps_cmd = format!(
254254+ "Get-CimInstance Win32_Process -Filter \"ProcessId={ppid}\" | Select-Object -ExpandProperty CommandLine"
255255+ );
256256+ let out = Command::new("powershell")
257257+ .arg("-NoProfile")
258258+ .arg("-Command")
259259+ .arg(ps_cmd)
260260+ .output();
261261+ if let Ok(o2) = out {
262262+ if o2.status.success() {
263263+ if let Ok(cmdline) = String::from_utf8(o2.stdout) {
264264+ let cmdline = cmdline.trim();
265265+ if !cmdline.is_empty() {
266266+ if let Some(idx) = cmdline.to_lowercase().find("run ") {
267267+ // preserve original-case remainder
268268+ let rest = cmdline[idx + "run ".len()..].trim();
269269+ if !rest.is_empty() {
270270+ return format!("run {rest}");
271271+ }
272272+ }
273273+ }
274274+ }
275275+ }
276276+ }
277277+ } else if let Some(cmdline) = get_ps_command(ppid) {
278278+ let cmdline = cmdline.trim();
279279+ if let Some(idx) = cmdline.find("run ") {
280280+ let rest = cmdline[idx + "run ".len()..].trim();
281281+ if !rest.is_empty() {
282282+ return format!("run {rest}");
283283+ }
284284+ }
285285+ }
286286+ }
287287+ }
288288+ }
289289+ }
290290+291291+ // If we didn't detect a wrapper like 'run', return the invocation actually used.
292292+ default_exe
293293+}
294294+295295+// hook_script returns a shell-specific hook that will invoke exec_cmd to obtain completion items.
296296+fn hook_script(shell: &str, exec_cmd: &str) -> String {
297297+ let s = shell.to_lowercase();
298298+ // single-quoted exec_cmd for safe embedding
299299+ let esc = shell_single_quote(exec_cmd);
300300+ // Use template placeholders {{EXEC}} then replace to avoid Rust format! interpreting shell braces
301301+ match s.as_str() {
302302+ "bash" => {
303303+ let tpl = r#"# van bash hook
304304+EXEC_CMD={{EXEC}}
305305+_van_completion() {
306306+ local cur compword i
307307+ cur="${COMP_WORDS[COMP_CWORD]}"
308308+ # build args: skip the command itself
309309+ local args=()
310310+ for ((i=1;i<${#COMP_WORDS[@]};i++)); do
311311+ args+=("${COMP_WORDS[i]}")
312312+ done
313313+ local IFS=$'\n'
314314+ local out
315315+ out=$(eval "$EXEC_CMD \"${args[@]}\"") || return
316316+ COMPREPLY=($(compgen -W "$out" -- "$cur"))
317317+}
318318+# Register _van_completion for all commands found in PATH (may be slow on very large PATHs)
319319+for cmd in $(compgen -c); do
320320+ complete -F _van_completion -o default "$cmd" 2>/dev/null || true
321321+done
322322+"#;
323323+ tpl.replace("{{EXEC}}", &esc)
324324+ }
325325+ "zsh" => {
326326+ let tpl = r#"# van zsh hook
327327+EXEC_CMD={{EXEC}}
328328+_van_completion() {
329329+ # words array contains all words; remove the command itself
330330+ local -a reply
331331+ reply=("${(@f)$(eval "$EXEC_CMD ${words[1,-1]}")}")
332332+ if [[ -n ${reply} ]]; then
333333+ compadd -- "${reply[@]}"
334334+ fi
335335+}
336336+# Register for all commands available in this shell
337337+for cmd in ${(k)commands}; do
338338+ compdef _van_completion $cmd 2>/dev/null || true
339339+done
340340+"#;
341341+ tpl.replace("{{EXEC}}", &esc)
342342+ }
343343+ "fish" => {
344344+ let tpl = r#"# van fish hook
345345+set -l VAN_EXEC {{EXEC}}
346346+function __van_completion
347347+ # get full commandline
348348+ set -l cmdline (commandline -cp)
349349+ # split into tokens by space (basic split)
350350+ set -l tokens (string split ' ' -- $cmdline)
351351+ # drop the leading command name
352352+ set -e tokens[1]
353353+ # call $VAN_EXEC with remaining tokens and print each candidate on its own line
354354+ for item in (eval "$VAN_EXEC $tokens")
355355+ printf "%s\n" "$item"
356356+ end
357357+end
358358+# Register completion for every executable in $PATH (may be slow)
359359+for p in (string split : $PATH)
360360+ for cmd in (ls $p 2>/dev/null)
361361+ complete -c $cmd -f -a '(__van_completion)'
362362+ end
363363+end
364364+"#;
365365+ tpl.replace("{{EXEC}}", &esc)
366366+ }
367367+ "nushell" | "nu" => {
368368+ let tpl = r#"# van nushell hook
369369+# Nushell custom completion support varies by version. The following provides a simple helper function
370370+# you can call from your nushell config to get completions for the current command line.
371371+# Example (in your config):
372372+# def van-complete [] { {{EXEC_RAW}} ($nu.env.CMDLINE | split ' ' | skip 1) }
373373+# Consult nushell docs for registering completion functions in your version.
374374+"#;
375375+ // nushell example uses unquoted raw exec_cmd; provide raw (not shell-single-quoted) replacement
376376+ tpl.replace("{{EXEC_RAW}}", exec_cmd)
377377+ }
378378+ _ => {
379379+ let tpl = r#"# van (default=bash) hook
380380+EXEC_CMD={{EXEC}}
381381+_van_completion() {
382382+ local cur compword i
383383+ cur="${COMP_WORDS[COMP_CWORD]}"
384384+ # build args: skip the command itself
385385+ local args=()
386386+ for ((i=1;i<${#COMP_WORDS[@]};i++)); do
387387+ args+=("${COMP_WORDS[i]}")
388388+ done
389389+ local IFS=$'\n'
390390+ local out
391391+ out=$(eval "$EXEC_CMD \"${args[@]}\"") || return
392392+ COMPREPLY=($(compgen -W "$out" -- "$cur"))
393393+}
394394+# Register _van_completion for all commands found in PATH (may be slow on very large PATHs)
395395+for cmd in $(compgen -c); do
396396+ complete -F _van_completion -o default "$cmd" 2>/dev/null || true
397397+done
398398+"#;
399399+ tpl.replace("{{EXEC}}", &esc)
400400+ }
401401+ }
402402+}
403403+404404+fn detect_shell_from_env() -> String {
405405+ env::var("SHELL")
406406+ .ok()
407407+ .and_then(|p| {
408408+ Path::new(&p)
409409+ .file_name()
410410+ .and_then(|s| s.to_str().map(|s| s.to_string()))
411411+ })
412412+ .filter(|s| !s.is_empty())
413413+ .unwrap_or_else(|| "bash".to_string())
414414+}
415415+416416+fn get_ps_command(ppid: u32) -> Option<String> {
417417+ let out = Command::new("ps")
418418+ .arg("-p")
419419+ .arg(ppid.to_string())
420420+ .arg("-o")
421421+ .arg("command=")
422422+ .output()
423423+ .ok()?;
424424+ if !out.status.success() {
425425+ return None;
426426+ }
427427+ String::from_utf8(out.stdout)
428428+ .ok()
429429+ .map(|s| s.trim().to_string())
430430+}
431431+432432+#[tokio::main]
433433+async fn main() {
434434+ let args: Vec<String> = env::args().skip(1).collect();
435435+ // simple flag handling for --help and --hook
436436+ if !args.is_empty() {
437437+ if args[0] == "--help" || args[0] == "-h" {
438438+ print_help();
439439+ return;
440440+ }
441441+ // support: --hook [shell] and optional --exe <cmd> (can appear before or after)
442442+ let mut hook_idx: isize = -1;
443443+ let mut exe_val = String::new();
444444+ let mut i = 0usize;
445445+ while i < args.len() {
446446+ if args[i] == "--hook" {
447447+ hook_idx = i as isize;
448448+ // if next arg exists and doesn't start with '-', treat as shell token
449449+ if i + 1 < args.len() && !args[i + 1].starts_with('-') {
450450+ i += 1;
451451+ }
452452+ i += 1;
453453+ continue;
454454+ }
455455+ if args[i] == "--exe" && i + 1 < args.len() {
456456+ exe_val = args[i + 1].to_owned();
457457+ i += 2;
458458+ continue;
459459+ }
460460+ i += 1;
461461+ }
462462+ if hook_idx != -1 {
463463+ // determine shell param if provided
464464+ let shell = if (hook_idx as usize) + 1 < args.len()
465465+ && !args[(hook_idx as usize) + 1].starts_with('-')
466466+ {
467467+ args[(hook_idx as usize) + 1].to_owned()
468468+ } else {
469469+ detect_shell_from_env()
470470+ };
471471+ let mut exe_cmd = exe_val;
472472+ if exe_cmd.is_empty() {
473473+ exe_cmd = detect_exec_from_parent();
474474+ }
475475+ if exe_cmd.is_empty() {
476476+ exe_cmd = Path::new(&env::args().next().unwrap_or_default())
477477+ .file_name()
478478+ .and_then(|s| s.to_str())
479479+ .unwrap_or("")
480480+ .to_string();
481481+ }
482482+ print!("{}", hook_script(&shell, &exe_cmd));
483483+ return;
484484+ }
485485+ }
486486+487487+ // If args provided, use non-interactive parsing similar to tooling (<cmd> args), else run interactive TUI
488488+ if !args.is_empty() {
489489+ match noninteractive_run(args) {
490490+ Ok(out) => {
491491+ if !out.is_empty() {
492492+ println!("{out}");
493493+ }
494494+ process::exit(0);
495495+ }
496496+ Err(e) => {
497497+ eprintln!("{e}");
498498+ process::exit(2);
499499+ }
500500+ }
501501+ }
502502+503503+ // Run interactive program
504504+ let builder = Program::<TeaAdapter>::builder();
505505+ let program = match builder.build() {
506506+ Ok(p) => p,
507507+ Err(e) => {
508508+ eprintln!("failed to build program: {e:?}");
509509+ process::exit(2);
510510+ }
511511+ };
512512+ match program.run().await {
513513+ Ok(_final_model) => {
514514+ // Interactive run does not print preview; simply exit successfully
515515+ process::exit(0);
516516+ }
517517+ Err(e) => {
518518+ eprintln!("program error: {e:?}");
519519+ process::exit(2);
520520+ }
521521+ }
522522+}
+27
crates/src/ui.rs
···11+// UI module root: split implementation into focused submodules under `ui/`
22+33+pub mod model;
44+pub mod render;
55+pub mod run;
66+pub mod update;
77+88+// Re-export commonly used symbols so existing call sites keep working (e.g. `crate::ui::initial_model`).
99+pub use model::{ChooseItem, Model, initial_model, sort_items};
1010+pub use render::{
1111+ render_full, render_main_content, render_modeline, render_modeline_padded, render_preview_block,
1212+};
1313+pub use run::run;
1414+pub use update::handle_update;
1515+1616+// Messages used by the update logic
1717+#[derive(Clone, Debug, PartialEq, Eq)]
1818+pub enum Msg {
1919+ WindowSize { width: usize, height: usize },
2020+ KeyBackspace,
2121+ KeyEnter,
2222+ KeyEsc,
2323+ KeySpace,
2424+ Rune(char),
2525+ KeyUp,
2626+ KeyDown,
2727+}
···11+// Render module split into focused submodules to reduce file size and compiler warnings.
22+33+pub mod decorate;
44+pub mod full;
55+pub mod list;
66+pub mod modeline;
77+pub mod preview;
88+pub mod styles;
99+pub mod util;
1010+1111+pub use decorate::tested_string;
1212+pub use full::render_full;
1313+pub use list::{assigned_map, render_list_content, render_main_content, render_visible_items};
1414+pub use modeline::{render_modeline, render_modeline_padded};
1515+pub use preview::{render_preview, render_preview_block};
+143
crates/src/ui/render/decorate.rs
···11+use crate::ui::render::styles::{STYLE_ACE, STYLE_TYPED};
22+use std::collections::HashMap;
33+44+fn collect_candidate_runes(form: &str) -> (Vec<char>, Vec<usize>) {
55+ let mut runes = Vec::new();
66+ let mut positions = Vec::new();
77+ for (i, ch) in form.char_indices() {
88+ if crate::acekey::is_ace_rune(ch) {
99+ runes.push(ch);
1010+ positions.push(i);
1111+ }
1212+ }
1313+ (runes, positions)
1414+}
1515+1616+pub fn decorate_form(form: &str, typed: &str, assigned_seq: String) -> String {
1717+ let (candidate_runes, candidate_pos) = collect_candidate_runes(form);
1818+1919+ let mut assigned_pos: Vec<usize> = Vec::new();
2020+ if !assigned_seq.is_empty() {
2121+ let mut ci = 0usize;
2222+ let assigned_lower = assigned_seq.to_lowercase();
2323+ for ar_rune in assigned_lower.chars() {
2424+ let mut found: Option<usize> = None;
2525+ // Always start searching from the current candidate index. We want the
2626+ // AceKey positions returned by assign_ace_keys to be respected even
2727+ // when the user has already typed; otherwise the ace-character may
2828+ // be skipped and not highlighted.
2929+ let start = ci;
3030+ for (j, ch) in candidate_runes.iter().enumerate().skip(start) {
3131+ if ch.eq_ignore_ascii_case(&ar_rune) {
3232+ found = Some(j);
3333+ ci = j + 1;
3434+ break;
3535+ }
3636+ }
3737+ if let Some(idx) = found {
3838+ assigned_pos.push(idx);
3939+ } else {
4040+ assigned_pos.clear();
4141+ break;
4242+ }
4343+ }
4444+ }
4545+4646+ let typed_len = if !typed.is_empty() && !assigned_seq.is_empty() {
4747+ if crate::ui::model::leading_hyphen_count(typed) >= 2
4848+ && crate::ui::model::leading_hyphen_count(&assigned_seq)
4949+ < crate::ui::model::leading_hyphen_count(typed)
5050+ {
5151+ 0usize
5252+ } else {
5353+ let leftmost_unit_runes = if form.starts_with("--") {
5454+ 2usize
5555+ } else {
5656+ 1usize
5757+ };
5858+ let typed_lower = typed.to_lowercase();
5959+ let typed_no_hyph = typed_lower.trim_start_matches('-');
6060+ let mut tr: Vec<char> = crate::ui::render::tested_string(typed_no_hyph)
6161+ .chars()
6262+ .collect();
6363+ if leftmost_unit_runes > tr.len() {
6464+ tr.clear();
6565+ } else {
6666+ tr = tr.into_iter().skip(leftmost_unit_runes).collect();
6767+ }
6868+ let assigned_lower = assigned_seq.to_lowercase();
6969+ let ar: Vec<char> = crate::ui::render::tested_string(&assigned_lower)
7070+ .chars()
7171+ .collect();
7272+ let mut i = 0usize;
7373+ while i < tr.len() && i < ar.len() && tr[i] == ar[i] {
7474+ i += 1;
7575+ }
7676+ i
7777+ }
7878+ } else {
7979+ 0usize
8080+ };
8181+8282+ let mut out = String::with_capacity(form.len());
8383+ let assigned_index_set: HashMap<usize, usize> = assigned_pos
8484+ .iter()
8585+ .cloned()
8686+ .enumerate()
8787+ .map(|(ord, idx)| (idx, ord))
8888+ .collect();
8989+9090+ for (byte_idx, ch) in form.char_indices() {
9191+ if crate::acekey::is_ace_rune(ch) {
9292+ let cidx_opt = candidate_pos.iter().position(|&p| p == byte_idx);
9393+ if let Some(cidx) = cidx_opt {
9494+ if let Some(&ord) = assigned_index_set.get(&cidx) {
9595+ if typed.is_empty() {
9696+ if ord == 0 {
9797+ out.push_str(&STYLE_ACE.render(&ch.to_string()));
9898+ } else {
9999+ out.push(ch);
100100+ }
101101+ continue;
102102+ }
103103+ if ord < typed_len {
104104+ out.push_str(&STYLE_TYPED.render(&ch.to_string()));
105105+ continue;
106106+ }
107107+ if ord == typed_len {
108108+ out.push_str(&STYLE_ACE.render(&ch.to_string()));
109109+ continue;
110110+ }
111111+ out.push(ch);
112112+ } else {
113113+ out.push(ch);
114114+ }
115115+ } else {
116116+ out.push(ch);
117117+ }
118118+ } else {
119119+ out.push(ch);
120120+ }
121121+ }
122122+ out
123123+}
124124+125125+pub fn tested_string(s: &str) -> String {
126126+ s.to_string()
127127+}
128128+129129+#[cfg(test)]
130130+mod tests {
131131+ use super::*;
132132+133133+ #[test]
134134+ fn acekey_highlight_when_typed_keeps_magenta() {
135135+ // when assigned_seq contains the ace char, decorate_form must render that
136136+ // character using STYLE_ACE, even if the user has already typed it.
137137+ let assigned = "w".to_string();
138138+ let out = decorate_form("w", "w", assigned.clone());
139139+ assert!(out.contains(&crate::ui::render::styles::STYLE_ACE.render("w")));
140140+ let out2 = decorate_form("wc", "w", assigned);
141141+ assert!(out2.contains(&crate::ui::render::styles::STYLE_ACE.render("w")));
142142+ }
143143+}
+241
crates/src/ui/render/full.rs
···11+use crate::ui::model::Model;
22+33+pub fn render_full(m: &Model) -> String {
44+ let mut lines = m.render_preview_block();
55+ lines.extend(m.render_main_content().lines().map(str::to_string));
66+ let first_line = crate::ui::render::modeline::render_modeline_padded(m)
77+ .lines()
88+ .next()
99+ .unwrap_or("")
1010+ .to_string();
1111+ lines.push(first_line);
1212+ lines.join("\n")
1313+}
1414+1515+#[cfg(test)]
1616+mod tests {
1717+ use regex::Regex;
1818+1919+ // helper to strip ANSI CSI sequences from rendered output for assertions
2020+ fn strip_ansi(s: &str) -> String {
2121+ let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
2222+ re.replace_all(s, "").to_string()
2323+ }
2424+2525+ #[test]
2626+ fn render_full_matches_dimensions() {
2727+ // sample sizes to validate behavior across different terminal shapes
2828+ let sizes = [(80usize, 24usize), (100usize, 10usize), (40usize, 20usize)];
2929+3030+ for (w, h) in sizes.iter().cloned() {
3131+ // populate 50 entries so the viewport/pagination logic is exercised
3232+ let mut entries: Vec<(String, String)> = Vec::new();
3333+ for i in 0..50 {
3434+ let name = format!("cmd{}", i + 1);
3535+ let desc = format!("description {}", i + 1);
3636+ entries.push((name, desc));
3737+ }
3838+ let mut m = crate::ui::initial_model(entries);
3939+4040+ // simulate WindowSize message
4141+ m.update(crate::ui::Msg::WindowSize {
4242+ width: w,
4343+ height: h,
4444+ });
4545+4646+ // render the full view
4747+ let out = m.render_full();
4848+4949+ // strip ANSI escape sequences so we can measure plain character dimensions
5050+ let stripped = strip_ansi(&out);
5151+5252+ // collect lines and assert the rendered height matches requested height
5353+ let lines: Vec<&str> = stripped.lines().collect();
5454+ assert_eq!(
5555+ lines.len(),
5656+ h,
5757+ "height mismatch for {}x{}: got {} lines\n<<output>>\n{}",
5858+ w,
5959+ h,
6060+ lines.len(),
6161+ stripped
6262+ );
6363+6464+ // each line must have exactly `w` characters after stripping ANSI
6565+ for (idx, line) in lines.iter().enumerate() {
6666+ let lw = line.chars().count();
6767+ assert_eq!(
6868+ lw, w,
6969+ "width mismatch at line {idx} for {w}x{h}: got {lw} chars\nline: `{line}`\n<<output>>\n{stripped}"
7070+ );
7171+ }
7272+ }
7373+ }
7474+7575+ #[test]
7676+ fn modeline_is_last_line_and_exact_width() {
7777+ let (w, h) = (80usize, 24usize);
7878+ let entries: Vec<(String, String)> = Vec::new();
7979+ let mut m = crate::ui::initial_model(entries);
8080+ m.update(crate::ui::Msg::WindowSize {
8181+ width: w,
8282+ height: h,
8383+ });
8484+ let out = m.render_full();
8585+ let stripped = strip_ansi(&out);
8686+ let lines: Vec<&str> = stripped.lines().collect();
8787+ assert!(!lines.is_empty(), "no lines rendered");
8888+ let last = *lines.last().unwrap();
8989+ assert_eq!(
9090+ last.chars().count(),
9191+ w,
9292+ "modeline width mismatch: got {} expected {}\n<<output>>\n{}",
9393+ last.chars().count(),
9494+ w,
9595+ stripped
9696+ );
9797+ let modeline = crate::ui::render_modeline_padded(&m);
9898+ let modeline_stripped = strip_ansi(&modeline);
9999+ let modeline_first = modeline_stripped.lines().next().unwrap_or("");
100100+ assert_eq!(
101101+ last, modeline_first,
102102+ "modeline content mismatch:\n<<output>>\n{stripped}"
103103+ );
104104+ }
105105+106106+ #[test]
107107+ fn preview_box_first_three_lines() {
108108+ let (w, h) = (80usize, 24usize);
109109+ let entries: Vec<(String, String)> = Vec::new();
110110+ let mut m = crate::ui::initial_model(entries);
111111+ m.update(crate::ui::Msg::WindowSize {
112112+ width: w,
113113+ height: h,
114114+ });
115115+ let out = m.render_full();
116116+ let stripped = strip_ansi(&out);
117117+ let lines: Vec<&str> = stripped.lines().collect();
118118+ assert!(lines.len() >= 3, "not enough lines to contain preview box");
119119+ let preview_block = m.render_preview_block();
120120+ let helper_combined = preview_block.join("\n");
121121+ let helper_stripped = strip_ansi(&helper_combined);
122122+ let helper_lines: Vec<&str> = helper_stripped.lines().collect();
123123+ for i in 0..3 {
124124+ assert_eq!(
125125+ lines[i], helper_lines[i],
126126+ "preview box line {i} mismatch:\n<<output>>\n{stripped}"
127127+ );
128128+ }
129129+ }
130130+131131+ #[test]
132132+ fn main_content_matches_between_preview_and_modeline() {
133133+ let (w, h) = (80usize, 24usize);
134134+ let entries: Vec<(String, String)> = Vec::new();
135135+ let mut m = crate::ui::initial_model(entries);
136136+ m.update(crate::ui::Msg::WindowSize {
137137+ width: w,
138138+ height: h,
139139+ });
140140+ let full = m.render_full();
141141+ let full_stripped = strip_ansi(&full);
142142+ let mut full_lines: Vec<&str> = full_stripped.lines().collect();
143143+ assert!(
144144+ full_lines.len() >= 4,
145145+ "not enough lines in full render to extract main content"
146146+ );
147147+ let preview_block = m.render_preview_block();
148148+ let preview_combined = preview_block.join("\n");
149149+ let preview_stripped = strip_ansi(&preview_combined);
150150+ let preview_height = preview_stripped.lines().count();
151151+ let middle_from_full = if full_lines.len() > preview_height + 1 {
152152+ full_lines
153153+ .drain(preview_height..full_lines.len() - 1)
154154+ .collect::<Vec<&str>>()
155155+ } else {
156156+ vec![]
157157+ };
158158+ let main = m.render_main_content();
159159+ let main_stripped = strip_ansi(&main);
160160+ let main_lines: Vec<&str> = main_stripped.lines().collect();
161161+ let mut left = middle_from_full;
162162+ while left.last().is_some_and(|s| s.trim().is_empty()) {
163163+ left.pop();
164164+ }
165165+ let mut right = main_lines;
166166+ while right.last().is_some_and(|s| s.trim().is_empty()) {
167167+ right.pop();
168168+ }
169169+ assert_eq!(left.len(), right.len(), "main content line count mismatch");
170170+ for (i, (a, b)) in left.iter().zip(right.iter()).enumerate() {
171171+ assert_eq!(a, b, "main content line {i} mismatch");
172172+ }
173173+ }
174174+175175+ #[test]
176176+ fn main_content_uses_viewport() {
177177+ let (w, h) = (30usize, 10usize);
178178+ let mut m = crate::ui::initial_model(Vec::new());
179179+ let mut items: Vec<crate::ui::ChooseItem> = Vec::new();
180180+ for i in 0..40 {
181181+ let name = format!("cmd{}", i + 1);
182182+ items.push(crate::ui::ChooseItem {
183183+ kind: "cmd".to_string(),
184184+ label: name.clone(),
185185+ forms: vec![name.clone()],
186186+ flag_def: None,
187187+ cmd_def: None,
188188+ short: String::new(),
189189+ depth: 0,
190190+ });
191191+ }
192192+ m.items = items;
193193+ m.update(crate::ui::Msg::WindowSize {
194194+ width: w,
195195+ height: h,
196196+ });
197197+ let full = m.render_full();
198198+ let stripped = strip_ansi(&full);
199199+ let lines: Vec<&str> = stripped.lines().collect();
200200+ assert_eq!(
201201+ lines.len(),
202202+ h,
203203+ "full render height mismatch: got {} expected {}\n<<output>>\n{}",
204204+ lines.len(),
205205+ h,
206206+ stripped
207207+ );
208208+ for (idx, line) in lines.iter().enumerate() {
209209+ let lw = line.chars().count();
210210+ assert_eq!(
211211+ lw, w,
212212+ "width mismatch at line {idx}: got {lw} expected {w}\nline: `{line}`\n<<output>>\n{stripped}"
213213+ );
214214+ }
215215+ let modeline = crate::ui::render_modeline_padded(&m);
216216+ let modeline_stripped = strip_ansi(&modeline);
217217+ let total_pages = if m.per_page == 0 {
218218+ 1
219219+ } else {
220220+ m.items.len().div_ceil(m.per_page)
221221+ };
222222+ let expect_pag = format!("Page 1/{total_pages}");
223223+ assert!(
224224+ modeline_stripped.contains(&expect_pag),
225225+ "modeline does not show pagination\n<<output>>\n{full}"
226226+ );
227227+ let preview_block = m.render_preview_block();
228228+ let preview_height = preview_block.len();
229229+ let middle: Vec<&str> = if lines.len() > preview_height + 1 {
230230+ lines[preview_height..lines.len() - 1].to_vec()
231231+ } else {
232232+ Vec::new()
233233+ };
234234+ let expected_per = m.per_page;
235235+ assert_eq!(middle.len(), expected_per, "main content page size mismatch: got {middle_len} expected {expected_per}\n<<output>>\n{stripped}", middle_len = middle.len());
236236+ for (i, line) in middle.iter().enumerate().take(expected_per) {
237237+ let expect = format!("cmd{}", i + 1);
238238+ assert!(line.contains(&expect), "expected main content line {i} to contain `{expect}` but got `{line}`\n<<output>>\n{stripped}");
239239+ }
240240+ }
241241+}
+586
crates/src/ui/render/list.rs
···11+use crate::acekey::assign_ace_keys;
22+use crate::ui::model::leading_hyphen_count;
33+use crate::ui::model::{ChooseItem, DEFAULT_WIDTH, Model};
44+use crate::ui::render::decorate::decorate_form;
55+use crate::ui::render::styles::{STYLE_DESC, STYLE_LABEL, STYLE_LINENUM};
66+use crate::ui::render::util::normalize_and_pad;
77+use std::collections::{HashMap, HashSet};
88+99+// Collect forms in baseline order for a numeric baseline subset
1010+fn baseline_subset_forms(nb: &[usize], items: &[ChooseItem]) -> Vec<String> {
1111+ let mut subset_forms = Vec::new();
1212+ for &idx in nb.iter() {
1313+ if let Some(it) = items.get(idx) {
1414+ for f in &it.forms {
1515+ subset_forms.push(f.clone());
1616+ }
1717+ }
1818+ }
1919+ subset_forms
2020+}
2121+2222+// Given a list of forms and the typed buffer, produce the ace-key assignment map
2323+fn assign_prefix_map(forms: &[String], typed_raw: &str) -> HashMap<String, String> {
2424+ let assignments = assign_ace_keys(forms, typed_raw);
2525+ let mut assigned: HashMap<String, String> = forms.iter().cloned().map(|f| (f, String::new())).collect();
2626+ if let Some(asg) = assignments {
2727+ for a in asg.iter() {
2828+ if a.index < forms.len() {
2929+ assigned.insert(forms[a.index].clone(), a.prefix.clone());
3030+ }
3131+ }
3232+ }
3333+ assigned
3434+}
3535+3636+pub fn assigned_map(m: &Model) -> HashMap<String, String> {
3737+ // When Numeric mode is active, compute assignments only for the numeric-filtered subset.
3838+ if let Some(nb) = &m.numeric_baseline {
3939+ // Build forms for the baseline subset in the same order as baseline
4040+ let subset_forms = baseline_subset_forms(nb, &m.items);
4141+ return assign_prefix_map(&subset_forms, &m.typed_raw);
4242+ }
4343+4444+ // Default: use all items
4545+ let forms: Vec<String> = m
4646+ .items
4747+ .iter()
4848+ .flat_map(|it| it.forms.iter().cloned())
4949+ .collect();
5050+ assign_prefix_map(&forms, &m.typed_raw)
5151+}
5252+5353+fn render_visible_items_numeric(nb: &[usize], m: &Model) -> Vec<ChooseItem> {
5454+ // typed_raw should be digits
5555+ if !m.typed_raw.is_empty() && m.typed_raw.chars().all(|c| c.is_ascii_digit()) {
5656+ let matches: Vec<usize> = nb
5757+ .iter()
5858+ .filter_map(|&orig_idx| {
5959+ let num = (orig_idx + 1).to_string();
6060+ if num.starts_with(&m.typed_raw) {
6161+ Some(orig_idx)
6262+ } else {
6363+ None
6464+ }
6565+ })
6666+ .collect();
6767+ matches
6868+ .into_iter()
6969+ .filter_map(|i| m.items.get(i).cloned())
7070+ .collect()
7171+ } else {
7272+ // no typed digits yet: return full baseline items in baseline order
7373+ nb.iter().filter_map(|&i| m.items.get(i).cloned()).collect()
7474+ }
7575+}
7676+7777+fn render_visible_items_alpha(m: &Model) -> Vec<ChooseItem> {
7878+ let forms: Vec<String> = m
7979+ .items
8080+ .iter()
8181+ .flat_map(|it| it.forms.iter().cloned())
8282+ .collect();
8383+ let assignments = assign_ace_keys(&forms, &m.typed_raw);
8484+ let mut visible_forms: HashSet<String> = HashSet::new();
8585+8686+ if let Some(asg) = assignments {
8787+ for a in asg.iter() {
8888+ if a.index < forms.len() {
8989+ visible_forms.insert(forms[a.index].clone());
9090+ }
9191+ }
9292+ } else if m.typed.is_empty() {
9393+ visible_forms = forms.into_iter().collect();
9494+ }
9595+9696+ m.items
9797+ .iter()
9898+ .filter(|it| it.forms.iter().any(|f| visible_forms.contains(f)))
9999+ .cloned()
100100+ .collect()
101101+}
102102+103103+pub fn render_visible_items(m: &Model) -> Vec<ChooseItem> {
104104+ if let Some(nb) = &m.numeric_baseline {
105105+ render_visible_items_numeric(nb, m)
106106+ } else {
107107+ render_visible_items_alpha(m)
108108+ }
109109+}
110110+111111+fn compute_gutter_width(total: usize) -> usize {
112112+ if total == 0 {
113113+ return 1;
114114+ }
115115+ let gw = ((total as f64).log10().floor() as usize) + 1;
116116+ usize::max(gw, 3)
117117+}
118118+119119+fn format_num_str(num: usize, gutter_width: usize) -> String {
120120+ format!("{:>1$} │ ", num, gutter_width)
121121+}
122122+123123+// Build baseline numbers and order when numeric baseline is active
124124+fn build_baseline(m: &Model) -> Option<(Vec<String>, Vec<usize>)> {
125125+ if let Some(nb) = &m.numeric_baseline {
126126+ let v: Vec<String> = nb.iter().map(|&orig_idx| (orig_idx + 1).to_string()).collect();
127127+ if v.is_empty() {
128128+ None
129129+ } else {
130130+ Some((v, nb.clone()))
131131+ }
132132+ } else {
133133+ None
134134+ }
135135+}
136136+137137+// Given a baseline order and typed buffer, produce positions to render (vis_pos, orig_idx)
138138+fn collect_numeric_positions(nb_order: &[usize], typed: &str) -> Vec<(usize, usize)> {
139139+ let mut positions = Vec::new();
140140+ if !typed.is_empty() && typed.chars().all(|c| c.is_ascii_digit()) {
141141+ for (vis_pos, &orig_idx) in nb_order.iter().enumerate() {
142142+ let num = (orig_idx + 1).to_string();
143143+ if num.starts_with(typed) {
144144+ positions.push((vis_pos, orig_idx));
145145+ }
146146+ }
147147+ } else {
148148+ for (vis_pos, &orig_idx) in nb_order.iter().enumerate() {
149149+ positions.push((vis_pos, orig_idx));
150150+ }
151151+ }
152152+ positions
153153+}
154154+155155+fn build_label(it: &ChooseItem, assigned: &HashMap<String, String>, t_hyph: usize, m: &Model) -> Option<String> {
156156+ let mut parts = Vec::new();
157157+ for f in &it.forms {
158158+ if t_hyph >= 2 && leading_hyphen_count(f) < t_hyph {
159159+ continue;
160160+ }
161161+ parts.push(decorate_form(f, &m.typed_raw, assigned.get(f).cloned().unwrap_or_default()));
162162+ }
163163+ if parts.is_empty() {
164164+ None
165165+ } else {
166166+ Some(parts.join(", "))
167167+ }
168168+}
169169+170170+fn flag_suffix(it: &ChooseItem, m: &Model) -> Vec<String> {
171171+ let mut suffix = Vec::new();
172172+ if let Some(fd) = &it.flag_def {
173173+ if fd.requires_value {
174174+ let mut placeholder = "VALUE".to_string();
175175+ if !fd.longhand.is_empty() {
176176+ placeholder = fd.longhand.to_uppercase();
177177+ } else if !fd.shorthand.is_empty() {
178178+ placeholder = fd.shorthand.to_uppercase();
179179+ }
180180+ suffix.push(STYLE_DESC.render(&format!(" {placeholder}")));
181181+ suffix.push(STYLE_DESC.render(" "));
182182+ } else {
183183+ suffix.push(STYLE_DESC.render(" "));
184184+ }
185185+ if !fd.usage.is_empty() {
186186+ suffix.push(STYLE_DESC.render(&fd.usage));
187187+ }
188188+ let top_depth = m.ast.stack.len().saturating_sub(1);
189189+ if it.depth < top_depth && it.depth < m.ast.stack.len() {
190190+ let origin = &m.ast.stack[it.depth].name;
191191+ if !origin.is_empty() {
192192+ suffix.push(STYLE_DESC.render(&format!(" (from {origin})")));
193193+ }
194194+ }
195195+ }
196196+ suffix
197197+}
198198+199199+fn cmd_suffix(it: &ChooseItem) -> Option<String> {
200200+ let short_ref: &str = if !it.short.is_empty() {
201201+ it.short.as_str()
202202+ } else if let Some(cd) = &it.cmd_def {
203203+ cd.short.as_str()
204204+ } else {
205205+ ""
206206+ };
207207+ if short_ref.is_empty() {
208208+ None
209209+ } else {
210210+ Some(STYLE_DESC.render(&format!(" {short_ref}")))
211211+ }
212212+}
213213+214214+// Render a single ChooseItem into a line (without trailing newline). Returns None when nothing should be rendered.
215215+fn render_item_line(
216216+ it: &ChooseItem,
217217+ assigned: &HashMap<String, String>,
218218+ t_hyph: usize,
219219+ num_str: String,
220220+ m: &Model,
221221+) -> Option<String> {
222222+ let label = build_label(it, assigned, t_hyph, m)?;
223223+ let mut line_pieces: Vec<String> = vec![STYLE_LINENUM.render(&num_str), STYLE_LABEL.render(&label)];
224224+ line_pieces.extend(flag_suffix(it, m));
225225+ if let Some(s) = cmd_suffix(it) {
226226+ line_pieces.push(s);
227227+ }
228228+ Some(line_pieces.join(""))
229229+}
230230+231231+// Render when numeric baseline is active
232232+fn render_numeric_content(m: &Model, assigned: &HashMap<String, String>, bs: &Vec<String>, nb_order: &Vec<usize>, t_hyph: usize, gutter_width: usize) -> String {
233233+ let mut b = String::new();
234234+ let positions = collect_numeric_positions(nb_order, &m.typed_raw);
235235+ if positions.is_empty() {
236236+ return b;
237237+ }
238238+ let total_positions = positions.len();
239239+ let per_page = if m.per_page == 0 { total_positions } else { m.per_page };
240240+ let start_pos = m.page.saturating_mul(per_page);
241241+ let end_pos = usize::min(start_pos + per_page, total_positions);
242242+243243+ for pos_idx in start_pos..end_pos {
244244+ let (vis_pos, orig_idx) = positions[pos_idx];
245245+ if let Some(it) = m.items.get(orig_idx) {
246246+ let num_str = if vis_pos < bs.len() {
247247+ format!("{:>1$} │ ", bs[vis_pos], gutter_width)
248248+ } else {
249249+ format_num_str(orig_idx + 1, gutter_width)
250250+ };
251251+ if let Some(line) = render_item_line(it, assigned, t_hyph, num_str, m) {
252252+ b.push_str(&line);
253253+ b.push('\n');
254254+ }
255255+ }
256256+ }
257257+ b
258258+}
259259+260260+// Default non-numeric render path
261261+fn render_default_content(m: &Model, visible: &[ChooseItem], baseline_num_strs: &Option<Vec<String>>, assigned: &HashMap<String, String>, t_hyph: usize, gutter_width: usize, start: usize, end: usize) -> String {
262262+ let mut b = String::new();
263263+ for (idx, it) in visible.iter().enumerate().skip(start).take(end.saturating_sub(start)) {
264264+ let num_str = if let Some(bs) = baseline_num_strs {
265265+ if idx < bs.len() {
266266+ format!("{:>1$} │ ", bs[idx], gutter_width)
267267+ } else {
268268+ format_num_str(idx + 1, gutter_width)
269269+ }
270270+ } else {
271271+ format_num_str(idx + 1, gutter_width)
272272+ };
273273+274274+ if let Some(line) = render_item_line(it, assigned, t_hyph, num_str, m) {
275275+ b.push_str(&line);
276276+ b.push('\n');
277277+ }
278278+ }
279279+ b
280280+}
281281+282282+pub fn render_list_content(m: &Model, visible: &[ChooseItem]) -> String {
283283+ let assigned = m.assigned_map();
284284+285285+ // If numeric baseline is active, compute total from baseline for gutter width
286286+ let (total, per) = if let Some(nb) = &m.numeric_baseline {
287287+ // total for gutter calculation should reflect the largest original index number
288288+ // use the maximum orig_idx+1 so gutter width does not shrink during numeric filtering
289289+ let max_num = nb.iter().map(|&i| i + 1).max().unwrap_or(0);
290290+ let t = max_num;
291291+ (t, if m.per_page == 0 { t } else { m.per_page })
292292+ } else {
293293+ let t = visible.len();
294294+ (t, if m.per_page == 0 { t } else { m.per_page })
295295+ };
296296+297297+ if per == 0 {
298298+ return String::new();
299299+ }
300300+ let start = m.page.saturating_mul(per);
301301+ let end = usize::min(start + per, total);
302302+ let t_hyph = leading_hyphen_count(&m.typed_raw);
303303+ let gutter_width = compute_gutter_width(total);
304304+305305+ let baseline = build_baseline(m);
306306+307307+ // Numeric baseline path
308308+ if let Some((bs, nb_order)) = baseline.as_ref() {
309309+ return render_numeric_content(m, &assigned, bs, &nb_order, t_hyph, gutter_width);
310310+ }
311311+312312+ // Default non-numeric path
313313+ render_default_content(m, visible, &baseline.map(|(v, _)| v), &assigned, t_hyph, gutter_width, start, end)
314314+}
315315+316316+pub fn render_main_content(m: &Model) -> String {
317317+ let total_width = if m.screen_width > 0 {
318318+ m.screen_width
319319+ } else {
320320+ DEFAULT_WIDTH
321321+ };
322322+323323+ if m.in_value_mode {
324324+ let lines: Vec<String> = vec![
325325+ lipgloss::Style::new().bold(true).render("Value input: ") + &m.pending_value,
326326+ lipgloss::Style::new()
327327+ .faint(true)
328328+ .render("Press Enter to confirm, Esc to cancel"),
329329+ ];
330330+ let per = if m.per_page == 0 { lines.len() } else { m.per_page };
331331+ return normalize_and_pad(lines, total_width, per);
332332+ }
333333+334334+ let visible = m.render_visible_items();
335335+ let list_block = m.render_list_content(&visible);
336336+ let lines: Vec<String> = list_block.lines().map(|s| s.to_string()).collect();
337337+ let per = if m.per_page == 0 {
338338+ lines.len()
339339+ } else {
340340+ m.per_page
341341+ };
342342+ // Ensure we return exactly `per` lines each normalized to the terminal width.
343343+ normalize_and_pad(lines, total_width, per)
344344+}
345345+346346+#[cfg(test)]
347347+mod tests {
348348+ use regex::Regex;
349349+350350+ fn strip_ansi(s: &str) -> String {
351351+ let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
352352+ re.replace_all(s, "").to_string()
353353+ }
354354+355355+ #[test]
356356+ fn render_assigned_map_initial_prefixes_shows_labels() {
357357+ let mut m = crate::ui::initial_model(vec![]);
358358+ m.items = vec![
359359+ crate::ui::ChooseItem {
360360+ kind: "flag".to_string(),
361361+ label: "--long".to_string(),
362362+ forms: vec!["--long".to_string()],
363363+ flag_def: None,
364364+ cmd_def: None,
365365+ short: String::new(),
366366+ depth: 0,
367367+ },
368368+ crate::ui::ChooseItem {
369369+ kind: "flag".to_string(),
370370+ label: "-s".to_string(),
371371+ forms: vec!["-s".to_string()],
372372+ flag_def: None,
373373+ cmd_def: None,
374374+ short: String::new(),
375375+ depth: 0,
376376+ },
377377+ crate::ui::ChooseItem {
378378+ kind: "cmd".to_string(),
379379+ label: "cmd".to_string(),
380380+ forms: vec!["cmd".to_string()],
381381+ flag_def: None,
382382+ cmd_def: None,
383383+ short: String::new(),
384384+ depth: 0,
385385+ },
386386+ ];
387387+ m.typed_raw = "".to_string();
388388+ let visible = m.render_visible_items();
389389+ let list = m.render_list_content(&visible);
390390+ let stripped = strip_ansi(&list);
391391+ assert!(stripped.contains("--long"));
392392+ assert!(stripped.contains("-s"));
393393+ assert!(stripped.contains("cmd"));
394394+ }
395395+396396+ #[test]
397397+ fn render_build_items_from_command_includes_flags_and_subcommands() {
398398+ let mut m = crate::ui::initial_model(vec![]);
399399+ let def = crate::ast::CommandDef {
400400+ name: "root".to_string(),
401401+ short: "rootcmd".to_string(),
402402+ aliases: vec![],
403403+ flags: vec![crate::ast::FlagDef {
404404+ longhand: "verbose".to_string(),
405405+ shorthand: "v".to_string(),
406406+ usage: "v".to_string(),
407407+ requires_value: false,
408408+ }],
409409+ subcommands: vec![crate::ast::CommandDef {
410410+ name: "sub".to_string(),
411411+ short: "subcmd".to_string(),
412412+ aliases: vec![],
413413+ flags: vec![],
414414+ subcommands: vec![],
415415+ }],
416416+ };
417417+ m.ast = crate::ast::Segment::new_empty("root");
418418+ m.current = Some(def.clone());
419419+ m.build_items_from_command(&def);
420420+ let visible = m.render_visible_items();
421421+ let list = m.render_list_content(&visible);
422422+ let stripped = strip_ansi(&list);
423423+ assert!(stripped.contains("--verbose") || stripped.contains("-v"));
424424+ assert!(stripped.contains("sub"));
425425+ }
426426+427427+ #[test]
428428+ fn render_flag_add_remove_toggle_and_render() {
429429+ let mut m = crate::ui::initial_model(vec![]);
430430+ let def = crate::ast::CommandDef {
431431+ name: "root".to_string(),
432432+ short: "rootcmd".to_string(),
433433+ aliases: vec![],
434434+ flags: vec![
435435+ crate::ast::FlagDef {
436436+ longhand: "message".to_string(),
437437+ shorthand: "m".to_string(),
438438+ usage: "msg".to_string(),
439439+ requires_value: true,
440440+ },
441441+ crate::ast::FlagDef {
442442+ longhand: "verbose".to_string(),
443443+ shorthand: "v".to_string(),
444444+ usage: "v".to_string(),
445445+ requires_value: false,
446446+ },
447447+ ],
448448+ subcommands: vec![],
449449+ };
450450+ m.ast = crate::ast::Segment::new_empty("root");
451451+ m.current = Some(def.clone());
452452+ m.build_items_from_command(&def);
453453+ m.ast.add_flag_to_depth(0, "--verbose", "");
454454+ let preview = m.render_preview();
455455+ let stripped = strip_ansi(&preview);
456456+ assert!(stripped.contains("--verbose"));
457457+ let removed = m.ast.remove_flag_from_depth("--verbose", 0);
458458+ assert!(removed);
459459+ let preview2 = m.render_preview();
460460+ let stripped2 = strip_ansi(&preview2);
461461+ assert!(!stripped2.contains("--verbose"));
462462+ m.ast.add_flag_to_depth(0, "--message", "hello");
463463+ let preview3 = m.render_preview();
464464+ assert!(strip_ansi(&preview3).contains("--message"));
465465+ }
466466+467467+ #[test]
468468+ fn render_add_positionals_and_undo_to_root() {
469469+ let mut m = crate::ui::initial_model(vec![]);
470470+ m.ast = crate::ast::Segment::new_empty("root");
471471+ m.ast.push_subcommand("sub");
472472+ m.ast.add_flag_to_depth(0, "--rootflag", "");
473473+ m.ast.add_positional("a");
474474+ m.ast.add_positional("b");
475475+ let p = strip_ansi(&m.render_preview());
476476+ assert_eq!(p, "root --rootflag sub a b");
477477+ m.ast.remove_last();
478478+ assert_eq!(strip_ansi(&m.render_preview()), "root --rootflag sub a");
479479+ m.ast.remove_last();
480480+ assert_eq!(strip_ansi(&m.render_preview()), "root --rootflag sub");
481481+ m.ast.remove_last();
482482+ assert_eq!(strip_ansi(&m.render_preview()), "root sub");
483483+ m.ast.remove_last();
484484+ assert_eq!(strip_ansi(&m.render_preview()).trim(), "root");
485485+ }
486486+487487+ #[test]
488488+ fn render_parent_and_subcommand_flags_preview_and_undo() {
489489+ let mut m = crate::ui::initial_model(vec![]);
490490+ m.ast = crate::ast::Segment::new_empty("root");
491491+ m.ast.push_subcommand("sub");
492492+ m.ast.add_flag_to_depth(0, "--rootflag", "");
493493+ m.ast.add_flag_to_depth(1, "--subflag", "");
494494+ assert!(
495495+ strip_ansi(&m.render_preview()).contains("--rootflag")
496496+ && strip_ansi(&m.render_preview()).contains("--subflag")
497497+ );
498498+ m.ast.remove_last();
499499+ assert!(!strip_ansi(&m.render_preview()).contains("--subflag"));
500500+ }
501501+502502+ #[test]
503503+ fn render_typed_buffer_preserved_and_highlighted_on_ambiguity() {
504504+ let mut m = crate::ui::initial_model(vec![]);
505505+ m.items = vec![
506506+ crate::ui::ChooseItem {
507507+ kind: "cmd".to_string(),
508508+ label: "chcpu".to_string(),
509509+ forms: vec!["chcpu".to_string()],
510510+ flag_def: None,
511511+ cmd_def: None,
512512+ short: String::new(),
513513+ depth: 0,
514514+ },
515515+ crate::ui::ChooseItem {
516516+ kind: "cmd".to_string(),
517517+ label: "chgrp".to_string(),
518518+ forms: vec!["chgrp".to_string()],
519519+ flag_def: None,
520520+ cmd_def: None,
521521+ short: String::new(),
522522+ depth: 0,
523523+ },
524524+ crate::ui::ChooseItem {
525525+ kind: "cmd".to_string(),
526526+ label: "chroot".to_string(),
527527+ forms: vec!["chroot".to_string()],
528528+ flag_def: None,
529529+ cmd_def: None,
530530+ short: String::new(),
531531+ depth: 0,
532532+ },
533533+ crate::ui::ChooseItem {
534534+ kind: "cmd".to_string(),
535535+ label: "chpasswd".to_string(),
536536+ forms: vec!["chpasswd".to_string()],
537537+ flag_def: None,
538538+ cmd_def: None,
539539+ short: String::new(),
540540+ depth: 0,
541541+ },
542542+ ];
543543+ m.typed_raw = "c".to_string();
544544+545545+ // filtered visible items should include multiple candidates (ambiguity)
546546+ let visible = m.render_visible_items();
547547+ assert!(
548548+ visible.len() >= 2,
549549+ "expected at least two visible candidates when typed 'c'"
550550+ );
551551+552552+ // assigned disambiguators should be present for the visible forms
553553+ let assigned = m.assigned_map();
554554+ for it in &visible {
555555+ for f in &it.forms {
556556+ let pref = assigned.get(f).cloned().unwrap_or_default();
557557+ assert!(!pref.is_empty(), "expected disambiguator for form {f}");
558558+ }
559559+ }
560560+561561+ // typed buffer should be preserved in the model mode
562562+ // model.mode() reflects the normalized typed buffer (`typed`), ensure it's set
563563+ m.typed = "c".to_string();
564564+ assert_eq!(m.mode(), "Typed: c");
565565+566566+ // ACE highlight must still be present in the rendered output for at least
567567+ // one of the assigned disambiguators (ANSI-coded). We don't require it
568568+ // to be 'c' specifically because assign_ace_keys may choose a different
569569+ // disambiguator rune in the filtered set.
570570+ let list = m.render_list_content(&visible);
571571+ let mut found_ace = false;
572572+ for (_k, v) in assigned.iter() {
573573+ if !v.is_empty() {
574574+ let styled = crate::ui::render::styles::STYLE_ACE.render(v);
575575+ if list.contains(&styled) {
576576+ found_ace = true;
577577+ break;
578578+ }
579579+ }
580580+ }
581581+ assert!(
582582+ found_ace,
583583+ "expected at least one ACE-styled disambiguator present in rendered list"
584584+ );
585585+ }
586586+}
+226
crates/src/ui/render/modeline.rs
···11+use crate::ui::model::{ChooseItem, DEFAULT_WIDTH, Model};
22+use crate::ui::render::styles::STYLE_MODELINE;
33+use lipgloss::Color;
44+55+pub fn render_modeline(m: &Model, inner_max: usize, mode: &str, visible: &[ChooseItem]) -> String {
66+ // Build styled pairs, compute plain widths, and fit pagination into available space.
77+ let total = visible.len();
88+ let per = if m.per_page == 0 { total } else { m.per_page };
99+ let total_pages = if per > 0 { total.div_ceil(per) } else { 1 };
1010+1111+ // prepare inner styles without padding so spacing is under our control
1212+ let inner_style = STYLE_MODELINE.clone().padding(0, 0, 0, 0);
1313+ let key_style = STYLE_MODELINE
1414+ .clone()
1515+ .foreground(Color::from_rgb(238, 0, 238))
1616+ .bold(true)
1717+ .padding(0, 0, 0, 0);
1818+ let desc_style = STYLE_MODELINE.clone().padding(0, 0, 0, 0);
1919+ let pag_style = STYLE_MODELINE.clone().faint(true).padding(0, 0, 0, 0);
2020+2121+ // key/description pairs definitions
2222+ let pairs_def: Vec<(&str, &str)> =
2323+ vec![("␣", "arg"), ("⏎", "run"), ("⌫", "undo"), ("⎋", "quit")];
2424+2525+ // Build rendered pairs and their plain widths in one pass
2626+ let pairs: Vec<(String, usize)> = pairs_def
2727+ .iter()
2828+ .map(|(k, d)| {
2929+ let plain_len = d.chars().count() + 1 + k.chars().count();
3030+ let rendered = format!(
3131+ "{}{}{}",
3232+ desc_style.render(d),
3333+ inner_style.render(":"),
3434+ key_style.render(k)
3535+ );
3636+ (rendered, plain_len)
3737+ })
3838+ .collect();
3939+4040+ let pair_sep_rendered = inner_style.render(" ");
4141+ let pair_sep_width = 2usize;
4242+4343+ // build pagination plain and styled
4444+ let mut pag_plain = String::new();
4545+ let mut pag_rendered = String::new();
4646+ if total_pages > 1 {
4747+ pag_plain = format!("Page {}/{} ↑/↓", m.page + 1, total_pages);
4848+ let arrows = format!("{}/{}", key_style.render("↑"), key_style.render("↓"));
4949+ let pag_unstyled = format!("Page {}/{} ", m.page + 1, total_pages);
5050+ pag_rendered = pag_style.render(&format!("{pag_unstyled}{arrows}"));
5151+ }
5252+ let mut pag_width = pag_plain.chars().count();
5353+5454+ // Start with all pairs and compute left width
5555+ let mut pairs_count = pairs.len();
5656+ let mut left_joined_rendered = if pairs_count > 0 {
5757+ pairs
5858+ .iter()
5959+ .map(|(r, _)| r.clone())
6060+ .collect::<Vec<_>>()
6161+ .join(&pair_sep_rendered)
6262+ } else {
6363+ String::new()
6464+ };
6565+ let mut left_width = if pairs_count > 0 {
6666+ pairs.iter().map(|(_, w)| *w).sum::<usize>() + pair_sep_width * (pairs_count - 1)
6767+ } else {
6868+ 0
6969+ };
7070+7171+ // mode and separator widths (mode has padding of 2 chars in modeStyle)
7272+ let mode_len = mode.chars().count();
7373+ let mode_padding = 2usize; // Padding(0,1) adds 1 left + 1 right
7474+ let mode_w = mode_len + mode_padding;
7575+ let sep_w = " | ".chars().count();
7676+7777+ let avail = if inner_max > mode_w + sep_w {
7878+ inner_max - mode_w - sep_w
7979+ } else {
8080+ 0
8181+ };
8282+8383+ // drop rightmost pairs until left + pag fits into avail
8484+ while pairs_count > 0 && left_width + pag_width > avail {
8585+ // remove last pair
8686+ pairs_count -= 1;
8787+ if pairs_count > 0 {
8888+ left_width = pairs
8989+ .iter()
9090+ .take(pairs_count)
9191+ .map(|(_, w)| *w)
9292+ .sum::<usize>()
9393+ + pair_sep_width * (pairs_count - 1);
9494+ left_joined_rendered = pairs
9595+ .iter()
9696+ .take(pairs_count)
9797+ .map(|(r, _)| r.clone())
9898+ .collect::<Vec<_>>()
9999+ .join(&pair_sep_rendered);
100100+ } else {
101101+ left_width = 0;
102102+ left_joined_rendered.clear();
103103+ }
104104+ }
105105+106106+ // if still doesn't fit and pagination exists, shorten pagination to just "Page X/Y"
107107+ if left_width + pag_width > avail && !pag_plain.is_empty() {
108108+ let short_pag = format!("Page {}/{}", m.page + 1, total_pages);
109109+ pag_width = short_pag.chars().count();
110110+ pag_rendered = pag_style.render(&short_pag);
111111+ }
112112+113113+ // compute filler width (subtract 2 to keep spacing consistent)
114114+ let pad = if avail > left_width + pag_width + 2 {
115115+ avail - left_width - pag_width - 2
116116+ } else {
117117+ 0
118118+ };
119119+ let filler = if pad > 0 {
120120+ STYLE_MODELINE.clone().width(pad as i32).render("")
121121+ } else {
122122+ String::new()
123123+ };
124124+125125+ let footer_inner = format!("{left_joined_rendered}{filler}{pag_rendered}");
126126+127127+ let mode_style = STYLE_MODELINE
128128+ .clone()
129129+ .background(Color::from_rgb(101, 101, 101))
130130+ .padding(0, 1, 0, 1)
131131+ .bold(true);
132132+ let mode_styled = mode_style.render(mode);
133133+134134+ // Indicator: show a dim single-char marker at the far left to indicate
135135+ // filtering mode. When numeric_baseline is present show '1', otherwise 'A'.
136136+ let indicator_char = if m.numeric_baseline.is_some() { "1" } else { "A" };
137137+ let indicator_style = STYLE_MODELINE.clone().faint(true).padding(0, 1, 0, 1);
138138+ let indicator_styled = indicator_style.render(indicator_char);
139139+140140+ let sep_styled = inner_style.render(" | ");
141141+ let rest_content = format!("{sep_styled}{footer_inner}");
142142+143143+ let trailing_pad = STYLE_MODELINE.render(" ");
144144+145145+ // Place the indicator to the far left followed by the mode block.
146146+ format!("{indicator_styled}{mode_styled}{rest_content}{trailing_pad}")
147147+}
148148+149149+pub fn render_modeline_padded(m: &Model) -> String {
150150+ // Compute total width and inner_max the same way render_full used to.
151151+ let total_width = if m.screen_width > 0 {
152152+ m.screen_width
153153+ } else {
154154+ DEFAULT_WIDTH
155155+ };
156156+ let inner_max = if total_width > 0 {
157157+ total_width.saturating_sub(2) - 1
158158+ } else {
159159+ DEFAULT_WIDTH
160160+ };
161161+ let visible = m.render_visible_items();
162162+ let mode = m.mode();
163163+ let modeline = render_modeline(m, inner_max, &mode, &visible);
164164+ let modeline_single = modeline.replace('\n', " ");
165165+ STYLE_MODELINE
166166+ .clone()
167167+ .width(total_width as i32)
168168+ .render(&modeline_single)
169169+}
170170+171171+#[cfg(test)]
172172+mod tests {
173173+ use regex::Regex;
174174+175175+ fn strip_ansi(s: &str) -> String {
176176+ let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
177177+ re.replace_all(s, "").to_string()
178178+ }
179179+180180+ #[test]
181181+ fn modeline_is_last_line_and_exact_width_small() {
182182+ let (w, h) = (80usize, 24usize);
183183+ let entries: Vec<(String, String)> = Vec::new();
184184+ let mut m = crate::ui::initial_model(entries);
185185+ m.update(crate::ui::Msg::WindowSize {
186186+ width: w,
187187+ height: h,
188188+ });
189189+ let modeline = crate::ui::render_modeline_padded(&m);
190190+ let modeline_stripped = strip_ansi(&modeline);
191191+ assert!(
192192+ modeline_stripped
193193+ .lines()
194194+ .next()
195195+ .unwrap_or("")
196196+ .chars()
197197+ .count()
198198+ <= w
199199+ );
200200+ }
201201+202202+ #[test]
203203+ fn modeline_shows_numeric_indicator_when_numeric_baseline() {
204204+ let (w, h) = (80usize, 24usize);
205205+ let entries: Vec<(String, String)> = Vec::new();
206206+ let mut m = crate::ui::initial_model(entries);
207207+ m.update(crate::ui::Msg::WindowSize { width: w, height: h });
208208+ // simulate numeric mode baseline captured
209209+ m.numeric_baseline = Some(vec![0, 1, 2]);
210210+ let modeline = crate::ui::render_modeline_padded(&m);
211211+ let modeline_stripped = strip_ansi(&modeline);
212212+ assert!(modeline_stripped.trim_start().starts_with('1'));
213213+ }
214214+215215+ #[test]
216216+ fn modeline_shows_alpha_indicator_when_not_numeric() {
217217+ let (w, h) = (80usize, 24usize);
218218+ let entries: Vec<(String, String)> = Vec::new();
219219+ let mut m = crate::ui::initial_model(entries);
220220+ m.update(crate::ui::Msg::WindowSize { width: w, height: h });
221221+ m.numeric_baseline = None;
222222+ let modeline = crate::ui::render_modeline_padded(&m);
223223+ let modeline_stripped = strip_ansi(&modeline);
224224+ assert!(modeline_stripped.trim_start().starts_with('A'));
225225+ }
226226+}
+26
crates/src/ui/render/preview.rs
···11+use crate::ui::model::{DEFAULT_WIDTH, Model, PREVIEW_BLOCK_LINES};
22+use crate::ui::render::styles::{STYLE_PREVIEW, STYLE_PREVIEW_BOX};
33+44+pub fn render_preview(m: &Model) -> String {
55+ STYLE_PREVIEW.render(&m.ast.render_preview())
66+}
77+88+pub fn render_preview_block(m: &Model) -> Vec<String> {
99+ let preview = m.ast.render_preview();
1010+ let preview_line = format!("> {preview}");
1111+ let box_width = if m.screen_width >= 2 {
1212+ m.screen_width - 2
1313+ } else {
1414+ DEFAULT_WIDTH
1515+ };
1616+ let w_i32: i32 = box_width.try_into().unwrap_or(i32::MAX);
1717+ let inner = STYLE_PREVIEW.render(&preview_line);
1818+ let preview_block = STYLE_PREVIEW_BOX.clone().width(w_i32).render(&inner);
1919+ let mut out: Vec<String> = preview_block.lines().map(|s| s.to_string()).collect();
2020+ // Ensure the preview block occupies exactly PREVIEW_BLOCK_LINES lines by truncating or padding with empty lines.
2121+ out.truncate(PREVIEW_BLOCK_LINES);
2222+ while out.len() < PREVIEW_BLOCK_LINES {
2323+ out.push(String::new());
2424+ }
2525+ out
2626+}