Skip to main content

strat9_kernel/shell/
mod.rs

1//! Chevron shell - Minimal interactive kernel shell
2//!
3//! Provides a simple interactive command-line interface for kernel management.
4//! Prompt: >>>
5
6// TODO UTF8
7//- clavier/layout renvoie des codepoints Unicode (pas seulement u8), puis conversion UTF‑8 pour l’édition.
8//- plus tard seulement, gestion graphemes/combinaisons complexes.
9
10pub mod commands;
11#[cfg(target_arch = "x86_64")]
12mod mouse;
13pub mod output;
14#[cfg(target_arch = "x86_64")]
15pub use mouse::mouse_task_main;
16pub mod parser;
17pub mod scripting;
18
19use commands::CommandRegistry;
20use output::{print_char, print_prompt, print_text};
21use parser::{parse_pipeline, Redirect};
22
23use crate::{shell_print, shell_println, sync::FixedQueue, vfs};
24use strat9_abi::flag::OpenFlags;
25
26/// Shell error types
27#[derive(Debug)]
28pub enum ShellError {
29    /// Unknown command
30    UnknownCommand,
31    /// Invalid arguments
32    InvalidArguments,
33    /// Command execution failed
34    ExecutionFailed,
35}
36
37use crate::arch::keyboard::{KEY_DOWN, KEY_END, KEY_HOME, KEY_LEFT, KEY_RIGHT, KEY_UP};
38use alloc::string::{String, ToString};
39use core::sync::atomic::{AtomicBool, Ordering};
40
41const SHELL_HISTORY_CAPACITY: usize = 50;
42
43/// Global flag set by Ctrl+C. Long-running commands should poll this
44/// via [`is_interrupted`] and abort early when it returns `true`.
45///
46/// # Cooperative cancellation contract
47///
48/// Any command that loops, polls, or performs a multi-step sequence
49/// MUST call [`is_interrupted()`] at least once per iteration and
50/// return promptly when it signals `true`.  This ensures the user
51/// can always break out with Ctrl+C.
52///
53/// Commands that already check keyboard input directly (e.g. `top`,
54/// `silo attach`) are exempt as long as they recognize Ctrl+C (`0x03`).
55pub static SHELL_INTERRUPTED: AtomicBool = AtomicBool::new(false);
56
57/// Returns `true` if Ctrl+C was pressed, and clears the flag.
58///
59/// Commands that loop (e.g. `watch`, `ping`, `gfx test3d`) should call this each
60/// iteration to support cancellation.
61pub fn is_interrupted() -> bool {
62    SHELL_INTERRUPTED.swap(false, Ordering::Relaxed)
63}
64
65/// Execute one shell line without prompt/history handling.
66///
67/// This helper is used by commands such as `watch` to run another
68/// command through the same parser/executor pipeline.
69pub fn run_line(line: &str) {
70    let registry = CommandRegistry::new();
71    execute_line(line, &registry);
72}
73
74/// Returns whether continuation byte.
75#[inline]
76fn is_continuation_byte(b: u8) -> bool {
77    (b & 0b1100_0000) == 0b1000_0000
78}
79
80/// Performs the prev char boundary operation.
81fn prev_char_boundary(input: &[u8], mut idx: usize) -> usize {
82    if idx == 0 {
83        return 0;
84    }
85    idx -= 1;
86    while idx > 0 && is_continuation_byte(input[idx]) {
87        idx -= 1;
88    }
89    idx
90}
91
92/// Performs the next char boundary operation.
93fn next_char_boundary(input: &[u8], mut idx: usize) -> usize {
94    if idx >= input.len() {
95        return input.len();
96    }
97    idx += 1;
98    while idx < input.len() && is_continuation_byte(input[idx]) {
99        idx += 1;
100    }
101    idx
102}
103
104/// Performs the char count operation.
105fn char_count(input: &[u8]) -> usize {
106    core::str::from_utf8(input)
107        .map(|s| s.chars().count())
108        .unwrap_or(input.len())
109}
110
111/// Performs the print bytes operation.
112fn print_bytes(input: &[u8]) {
113    if let Ok(s) = core::str::from_utf8(input) {
114        print_text(s);
115    } else {
116        let mut tmp = String::with_capacity(input.len());
117        for &b in input {
118            tmp.push(if b.is_ascii() { b as char } else { '?' });
119        }
120        print_text(&tmp);
121    }
122}
123
124/// Performs the move cursor left chars operation.
125fn move_cursor_left_chars(n: usize) {
126    if n == 0 {
127        return;
128    }
129    let mut tmp = String::with_capacity(n);
130    for _ in 0..n {
131        tmp.push('\x08');
132    }
133    print_text(&tmp);
134}
135
136/// Performs the clear visible line operation.
137fn clear_visible_line(line: &[u8]) {
138    let n = char_count(line);
139    if n == 0 {
140        return;
141    }
142    let mut tmp = String::with_capacity(n.saturating_mul(3));
143    for _ in 0..n {
144        tmp.push('\x08');
145    }
146    for _ in 0..n {
147        tmp.push(' ');
148    }
149    for _ in 0..n {
150        tmp.push('\x08');
151    }
152    print_text(&tmp);
153}
154
155/// Redraw the current shell input line after the prompt
156fn redraw_line(input: &[u8], cursor_pos: usize) {
157    let mut tmp = String::new();
158    if let Ok(s) = core::str::from_utf8(input) {
159        tmp.push_str(s);
160    } else {
161        tmp.reserve(input.len());
162        for &b in input {
163            tmp.push(if b.is_ascii() { b as char } else { '?' });
164        }
165    }
166    tmp.push(' ');
167    tmp.push('\x08');
168
169    let back_moves = if cursor_pos <= input.len() {
170        if let (Ok(full), Ok(prefix)) = (
171            core::str::from_utf8(input),
172            core::str::from_utf8(&input[..cursor_pos]),
173        ) {
174            full.chars().count().saturating_sub(prefix.chars().count())
175        } else {
176            input.len().saturating_sub(cursor_pos)
177        }
178    } else {
179        0
180    };
181    tmp.reserve(back_moves);
182    for _ in 0..back_moves {
183        tmp.push('\x08');
184    }
185    print_text(&tmp);
186}
187
188/// Performs the redraw full line operation.
189fn redraw_full_line(input: &[u8], cursor_pos: usize) {
190    let n = char_count(input);
191    let back_moves = if cursor_pos <= input.len() {
192        if let Ok(sfx) = core::str::from_utf8(&input[cursor_pos..]) {
193            sfx.chars().count()
194        } else {
195            input.len().saturating_sub(cursor_pos)
196        }
197    } else {
198        0
199    };
200
201    let mut tmp = String::with_capacity(
202        n.saturating_mul(2)
203            .saturating_add(input.len())
204            .saturating_add(back_moves),
205    );
206    for _ in 0..n {
207        tmp.push('\x08');
208    }
209    for _ in 0..n {
210        tmp.push(' ');
211    }
212    for _ in 0..n {
213        tmp.push('\x08');
214    }
215    if let Ok(s) = core::str::from_utf8(input) {
216        tmp.push_str(s);
217    } else {
218        for &b in input {
219            tmp.push(if b.is_ascii() { b as char } else { '?' });
220        }
221    }
222    for _ in 0..back_moves {
223        tmp.push('\x08');
224    }
225    print_text(&tmp);
226}
227
228/// Performs the insert bytes at cursor operation.
229fn insert_bytes_at_cursor(
230    input_buf: &mut [u8],
231    input_len: &mut usize,
232    cursor_pos: &mut usize,
233    bytes: &[u8],
234) -> bool {
235    if bytes.is_empty() {
236        return true;
237    }
238    if *input_len + bytes.len() > input_buf.len() {
239        return false;
240    }
241    let old_cursor = *cursor_pos;
242    if old_cursor < *input_len {
243        for i in (old_cursor..*input_len).rev() {
244            input_buf[i + bytes.len()] = input_buf[i];
245        }
246    }
247    input_buf[old_cursor..old_cursor + bytes.len()].copy_from_slice(bytes);
248    *input_len += bytes.len();
249    *cursor_pos += bytes.len();
250    redraw_line(&input_buf[old_cursor..*input_len], bytes.len());
251    true
252}
253
254/// Performs the delete prev char at cursor operation.
255fn delete_prev_char_at_cursor(
256    input_buf: &mut [u8],
257    input_len: &mut usize,
258    cursor_pos: &mut usize,
259) -> bool {
260    if *cursor_pos == 0 {
261        return false;
262    }
263
264    let prev = prev_char_boundary(&input_buf[..*input_len], *cursor_pos);
265    let removed = *cursor_pos - prev;
266    for i in *cursor_pos..*input_len {
267        input_buf[i - removed] = input_buf[i];
268    }
269    *input_len -= removed;
270    *cursor_pos = prev;
271
272    // Backspace behavior: visual cursor moves left by one character first.
273    move_cursor_left_chars(1);
274    redraw_line(&input_buf[*cursor_pos..*input_len], 0);
275    true
276}
277
278/// Performs the delete next char at cursor operation.
279fn delete_next_char_at_cursor(
280    input_buf: &mut [u8],
281    input_len: &mut usize,
282    cursor_pos: &mut usize,
283) -> bool {
284    if *cursor_pos >= *input_len {
285        return false;
286    }
287
288    let next = next_char_boundary(&input_buf[..*input_len], *cursor_pos);
289    let removed = next - *cursor_pos;
290    for i in next..*input_len {
291        input_buf[i - removed] = input_buf[i];
292    }
293    *input_len -= removed;
294
295    // Delete behavior: cursor stays at the same logical position.
296    redraw_line(&input_buf[*cursor_pos..*input_len], 0);
297    true
298}
299
300/// Main shell loop
301///
302/// This function never returns. It continuously reads keyboard input,
303/// parses commands, and executes them.
304pub extern "C" fn shell_main() -> ! {
305    // E9 'Q' (0x51) = first instruction, before any lock/serial. Confirms we reached shell.
306    crate::arch::serial::putc(0x51);
307    // 'S' = shell entered (avoids FORCE_LOCK contention with timer)
308    crate::arch::serial::putc(0x53);
309    let registry = CommandRegistry::new();
310    commands::util::init_shell_env();
311    let mut input_buf = [0u8; 256];
312    let mut input_len = 0;
313    let mut cursor_pos = 0;
314    // 'L' = init complete, entering main loop
315    crate::arch::serial::putc(0x4C);
316
317    // Command history
318    let mut history: FixedQueue<String, SHELL_HISTORY_CAPACITY> = FixedQueue::new();
319    let mut history_idx: isize = -1;
320    let mut current_input_saved = String::new();
321    let mut utf8_pending = [0u8; 4];
322    let mut utf8_pending_len = 0usize;
323    let mut in_escape_seq = false;
324
325    // Display welcome message using ASCII for robust terminal rendering.
326    shell_println!("");
327    shell_println!("+--------------------------------------------------------------+");
328    shell_println!("|         Strat9-OS chevron shell v0.1.0                       |");
329    shell_println!("|         Type 'help' for available commands                   |");
330    shell_println!("+--------------------------------------------------------------+");
331    shell_println!("");
332
333    print_prompt();
334
335    let mut last_blink_tick = 0;
336    let mut cursor_visible = false;
337
338    loop {
339        // Handle cursor blinking (graphics only)
340        let ticks = crate::process::scheduler::ticks();
341
342        if ticks / 50 != last_blink_tick {
343            last_blink_tick = ticks / 50;
344            cursor_visible = !cursor_visible;
345
346            if crate::arch::vga::is_available() {
347                if cursor_visible {
348                    let color = crate::arch::vga::RgbColor::new(0x4F, 0xB3, 0xB3); // Cyan
349                    crate::arch::vga::draw_text_cursor(color);
350                } else {
351                    crate::arch::vga::hide_text_cursor();
352                }
353            }
354        }
355
356        if let Some(ch) = crate::arch::keyboard::read_char() {
357            // Any keypress returns the view to live output.
358            if crate::arch::vga::is_available() {
359                crate::arch::vga::scroll_to_live();
360            }
361
362            // Hide cursor before any action
363            if crate::arch::vga::is_available() {
364                crate::arch::vga::hide_text_cursor();
365            }
366
367            match ch {
368                b'\r' | b'\n' => {
369                    in_escape_seq = false;
370                    utf8_pending_len = 0;
371                    shell_println!();
372
373                    if input_len > 0 {
374                        let line = core::str::from_utf8(&input_buf[..input_len]).unwrap_or("");
375
376                        if !line.is_empty() {
377                            if history.is_empty()
378                                || history.back().map(|s: &String| s.as_str()) != Some(line)
379                            {
380                                if history.is_full() {
381                                    let _ = history.pop_front();
382                                }
383                                history.push_back(line.to_string()).expect(
384                                    "shell history push must succeed after dropping oldest entry",
385                                );
386                            }
387                        }
388
389                        execute_line(line, &registry);
390                        input_len = 0;
391                        cursor_pos = 0;
392                        history_idx = -1;
393                    }
394
395                    print_prompt();
396                }
397                b'\x08' | b'\x7f' => {
398                    in_escape_seq = false;
399                    utf8_pending_len = 0;
400                    let _ =
401                        delete_prev_char_at_cursor(&mut input_buf, &mut input_len, &mut cursor_pos);
402                }
403                b'\x03' => {
404                    in_escape_seq = false;
405                    utf8_pending_len = 0;
406                    shell_println!("^C");
407                    input_len = 0;
408                    cursor_pos = 0;
409                    history_idx = -1;
410                    SHELL_INTERRUPTED.store(false, Ordering::Relaxed);
411                    print_prompt();
412                }
413                b'\t' => {
414                    in_escape_seq = false;
415                    utf8_pending_len = 0;
416                    tab_complete(&mut input_buf, &mut input_len, &mut cursor_pos, &registry);
417                }
418                b'\x04' => {
419                    in_escape_seq = false;
420                    utf8_pending_len = 0;
421                    let _ =
422                        delete_next_char_at_cursor(&mut input_buf, &mut input_len, &mut cursor_pos);
423                }
424                KEY_LEFT => {
425                    in_escape_seq = false;
426                    utf8_pending_len = 0;
427                    if cursor_pos > 0 {
428                        cursor_pos = prev_char_boundary(&input_buf[..input_len], cursor_pos);
429                        print_char('\x08');
430                    }
431                }
432                KEY_RIGHT => {
433                    in_escape_seq = false;
434                    utf8_pending_len = 0;
435                    if cursor_pos < input_len {
436                        let next = next_char_boundary(&input_buf[..input_len], cursor_pos);
437                        print_bytes(&input_buf[cursor_pos..next]);
438                        cursor_pos = next;
439                    }
440                }
441                KEY_HOME => {
442                    in_escape_seq = false;
443                    utf8_pending_len = 0;
444                    while cursor_pos > 0 {
445                        cursor_pos = prev_char_boundary(&input_buf[..input_len], cursor_pos);
446                        print_char('\x08');
447                    }
448                }
449                KEY_END => {
450                    in_escape_seq = false;
451                    utf8_pending_len = 0;
452                    while cursor_pos < input_len {
453                        let next = next_char_boundary(&input_buf[..input_len], cursor_pos);
454                        print_bytes(&input_buf[cursor_pos..next]);
455                        cursor_pos = next;
456                    }
457                }
458                KEY_UP => {
459                    in_escape_seq = false;
460                    utf8_pending_len = 0;
461                    if !history.is_empty() && history_idx < (history.len() as isize - 1) {
462                        if history_idx == -1 {
463                            current_input_saved = core::str::from_utf8(&input_buf[..input_len])
464                                .unwrap_or("")
465                                .to_string();
466                        }
467
468                        while cursor_pos < input_len {
469                            let next = next_char_boundary(&input_buf[..input_len], cursor_pos);
470                            print_bytes(&input_buf[cursor_pos..next]);
471                            cursor_pos = next;
472                        }
473                        clear_visible_line(&input_buf[..input_len]);
474
475                        history_idx += 1;
476                        let hist_str = history
477                            .get(history.len() - 1 - history_idx as usize)
478                            .expect("shell history index must be in range");
479                        let bytes = hist_str.as_bytes();
480                        let copy_len = bytes.len().min(input_buf.len());
481                        input_buf[..copy_len].copy_from_slice(&bytes[..copy_len]);
482                        input_len = copy_len;
483                        cursor_pos = input_len;
484
485                        redraw_full_line(&input_buf[..input_len], cursor_pos);
486                    }
487                }
488                KEY_DOWN => {
489                    in_escape_seq = false;
490                    utf8_pending_len = 0;
491                    if history_idx >= 0 {
492                        while cursor_pos < input_len {
493                            let next = next_char_boundary(&input_buf[..input_len], cursor_pos);
494                            print_bytes(&input_buf[cursor_pos..next]);
495                            cursor_pos = next;
496                        }
497                        clear_visible_line(&input_buf[..input_len]);
498
499                        history_idx -= 1;
500                        if history_idx == -1 {
501                            let bytes = current_input_saved.as_bytes();
502                            let copy_len = bytes.len().min(input_buf.len());
503                            input_buf[..copy_len].copy_from_slice(&bytes[..copy_len]);
504                            input_len = copy_len;
505                        } else {
506                            let hist_str = history
507                                .get(history.len() - 1 - history_idx as usize)
508                                .expect("shell history index must be in range");
509                            let bytes = hist_str.as_bytes();
510                            let copy_len = bytes.len().min(input_buf.len());
511                            input_buf[..copy_len].copy_from_slice(&bytes[..copy_len]);
512                            input_len = copy_len;
513                        }
514                        cursor_pos = input_len;
515
516                        redraw_full_line(&input_buf[..input_len], cursor_pos);
517                    }
518                }
519                b'\x1b' => {
520                    utf8_pending_len = 0;
521                    in_escape_seq = true;
522                }
523                _ if in_escape_seq => {
524                    if (0x40..=0x7E).contains(&ch) {
525                        in_escape_seq = false;
526                    } else if ch == b'[' || ch == b';' || ch == b'?' || ch.is_ascii_digit() {
527                        // stay in escape sequence
528                    } else {
529                        in_escape_seq = false;
530                    }
531                }
532                _ if ch >= 0x20 => {
533                    in_escape_seq = false;
534                    if ch < 0x80 {
535                        utf8_pending_len = 0;
536                        if insert_bytes_at_cursor(
537                            &mut input_buf,
538                            &mut input_len,
539                            &mut cursor_pos,
540                            core::slice::from_ref(&ch),
541                        ) {
542                            history_idx = -1;
543                        }
544                    } else {
545                        if utf8_pending_len >= utf8_pending.len() {
546                            utf8_pending_len = 0;
547                        }
548                        utf8_pending[utf8_pending_len] = ch;
549                        utf8_pending_len += 1;
550                        match core::str::from_utf8(&utf8_pending[..utf8_pending_len]) {
551                            Ok(s) => {
552                                if insert_bytes_at_cursor(
553                                    &mut input_buf,
554                                    &mut input_len,
555                                    &mut cursor_pos,
556                                    s.as_bytes(),
557                                ) {
558                                    history_idx = -1;
559                                }
560                                utf8_pending_len = 0;
561                            }
562                            Err(err) => {
563                                if err.error_len().is_some() {
564                                    utf8_pending_len = 0;
565                                }
566                            }
567                        }
568                    }
569                }
570                _ => {
571                    in_escape_seq = false;
572                    utf8_pending_len = 0;
573                }
574            }
575            // Reset blink state on input
576            last_blink_tick = ticks / 50;
577            cursor_visible = true;
578        }
579        crate::process::yield_task();
580    }
581}
582
583/// Execute a command line, handling scripting, pipes and redirections.
584fn execute_line(line: &str, registry: &CommandRegistry) {
585    let expanded = scripting::expand_vars(line);
586
587    match scripting::parse_script(&expanded) {
588        scripting::ScriptConstruct::SetVar { key, val } => {
589            let expanded_val = scripting::expand_vars(&val);
590            scripting::set_var(&key, &expanded_val);
591            scripting::set_last_exit(0);
592            return;
593        }
594        scripting::ScriptConstruct::UnsetVar(key) => {
595            scripting::unset_var(&key);
596            scripting::set_last_exit(0);
597            return;
598        }
599        scripting::ScriptConstruct::ForLoop { var, items, body } => {
600            for item in &items {
601                scripting::set_var(&var, item);
602                for cmd in &body {
603                    let exp = scripting::expand_vars(cmd);
604                    execute_pipeline(&exp, registry);
605                }
606            }
607            return;
608        }
609        scripting::ScriptConstruct::WhileLoop { cond, body } => {
610            let mut iters = 0u32;
611            loop {
612                if iters > 10000 || is_interrupted() {
613                    break;
614                }
615                let cond_expanded = scripting::expand_vars(&cond);
616                execute_pipeline(&cond_expanded, registry);
617                if scripting::last_exit() != 0 {
618                    break;
619                }
620                for cmd in &body {
621                    let exp = scripting::expand_vars(cmd);
622                    execute_pipeline(&exp, registry);
623                }
624                iters += 1;
625            }
626            return;
627        }
628        scripting::ScriptConstruct::IfElse {
629            cond,
630            then_body,
631            else_body,
632        } => {
633            let cond_expanded = scripting::expand_vars(&cond);
634            execute_pipeline(&cond_expanded, registry);
635            let branch = if scripting::last_exit() == 0 {
636                &then_body
637            } else {
638                &else_body
639            };
640            for cmd in branch {
641                let exp = scripting::expand_vars(cmd);
642                execute_pipeline(&exp, registry);
643            }
644            return;
645        }
646        scripting::ScriptConstruct::Simple(s) => {
647            execute_pipeline(&s, registry);
648        }
649    }
650}
651
652/// Execute a single pipeline (no scripting).
653fn execute_pipeline(line: &str, registry: &CommandRegistry) {
654    // Ensure stale pipe input from a previous command cannot leak.
655    output::clear_pipe_input();
656
657    let pipeline = match parse_pipeline(line) {
658        Some(p) => p,
659        None => return,
660    };
661
662    let stage_count = pipeline.stages.len();
663    let mut pipe_data: Option<alloc::vec::Vec<u8>> = None;
664
665    for (i, stage) in pipeline.stages.iter().enumerate() {
666        let is_last = i == stage_count - 1;
667        let needs_capture = !is_last || stage.stdout_redirect.is_some();
668
669        if let Some(ref stdin_path) = stage.stdin_redirect {
670            match vfs::open(stdin_path, vfs::OpenFlags::READ) {
671                Ok(fd) => {
672                    let data = vfs::read_all(fd).unwrap_or_default();
673                    let _ = vfs::close(fd);
674                    output::set_pipe_input(data);
675                }
676                Err(e) => {
677                    shell_println!("shell: cannot open '{}': {:?}", stdin_path, e);
678                    return;
679                }
680            }
681        } else if let Some(data) = pipe_data.take() {
682            output::set_pipe_input(data);
683        }
684
685        if needs_capture {
686            output::start_capture();
687        }
688
689        let result = registry.execute(&stage.command);
690
691        let captured = if needs_capture {
692            output::take_capture()
693        } else {
694            alloc::vec::Vec::new()
695        };
696
697        match result {
698            Ok(()) => {
699                scripting::set_last_exit(0);
700            }
701            Err(ShellError::UnknownCommand) => {
702                scripting::set_last_exit(127);
703                shell_println!("Error: unknown command '{}'", stage.command.name);
704                return;
705            }
706            Err(ShellError::InvalidArguments) => {
707                scripting::set_last_exit(2);
708                shell_println!("Error: invalid arguments for '{}'", stage.command.name);
709                return;
710            }
711            Err(ShellError::ExecutionFailed) => {
712                scripting::set_last_exit(1);
713                shell_println!("Error: '{}' execution failed", stage.command.name);
714                return;
715            }
716        }
717
718        // A stage may ignore stdin pipe input; clear any leftovers before next stage.
719        output::clear_pipe_input();
720
721        if let Some(ref redirect) = stage.stdout_redirect {
722            apply_redirect(redirect, &captured);
723        }
724
725        if !is_last {
726            pipe_data = Some(captured);
727        }
728    }
729}
730
731/// Tab completion for command names and VFS paths.
732///
733/// If the cursor is on the first token, completes against registered commands.
734/// Otherwise completes against VFS directory entries.
735fn tab_complete(
736    input_buf: &mut [u8],
737    input_len: &mut usize,
738    cursor_pos: &mut usize,
739    registry: &CommandRegistry,
740) {
741    let text = match core::str::from_utf8(&input_buf[..*input_len]) {
742        Ok(s) => s,
743        Err(_) => return,
744    };
745
746    let before_cursor = &text[..*cursor_pos];
747    let has_space = before_cursor.contains(' ');
748
749    if !has_space {
750        let prefix = before_cursor;
751        let names = registry.command_names();
752        let matches: alloc::vec::Vec<&str> = names
753            .iter()
754            .copied()
755            .filter(|n| n.starts_with(prefix))
756            .collect();
757
758        if matches.len() == 1 {
759            complete_replace_word(input_buf, input_len, cursor_pos, 0, matches[0], true);
760        } else if matches.len() > 1 {
761            let common = longest_common_prefix(&matches);
762            if common.len() > prefix.len() {
763                complete_replace_word(input_buf, input_len, cursor_pos, 0, &common, false);
764            } else {
765                shell_println!();
766                for m in &matches {
767                    shell_print!("{}  ", m);
768                }
769                shell_println!();
770                output::print_prompt();
771                print_bytes(&input_buf[..*input_len]);
772                let back = char_count(&input_buf[*cursor_pos..*input_len]);
773                move_cursor_left_chars(back);
774            }
775        }
776    } else {
777        let last_space = before_cursor.rfind(' ').unwrap_or(0);
778        let partial = &before_cursor[last_space + 1..];
779        let (dir, file_prefix) = if let Some(slash_pos) = partial.rfind('/') {
780            (&partial[..=slash_pos], &partial[slash_pos + 1..])
781        } else {
782            ("/", partial)
783        };
784
785        if let Ok(fd) = vfs::open(dir, OpenFlags::READ | OpenFlags::DIRECTORY) {
786            let entries = vfs::getdents(fd).unwrap_or_default();
787            let _ = vfs::close(fd);
788
789            let matches: alloc::vec::Vec<alloc::string::String> = entries
790                .iter()
791                .filter(|e| e.name != "." && e.name != ".." && e.name.starts_with(file_prefix))
792                .map(|e| {
793                    let mut s = alloc::string::String::from(dir);
794                    s.push_str(&e.name);
795                    if e.file_type == strat9_abi::data::DT_DIR {
796                        s.push('/');
797                    }
798                    s
799                })
800                .collect();
801
802            if matches.len() == 1 {
803                let add_space = !matches[0].ends_with('/');
804                complete_replace_word(
805                    input_buf,
806                    input_len,
807                    cursor_pos,
808                    last_space + 1,
809                    &matches[0],
810                    add_space,
811                );
812            } else if matches.len() > 1 {
813                let refs: alloc::vec::Vec<&str> = matches.iter().map(|s| s.as_str()).collect();
814                let common = longest_common_prefix(&refs);
815                if common.len() > partial.len() {
816                    complete_replace_word(
817                        input_buf,
818                        input_len,
819                        cursor_pos,
820                        last_space + 1,
821                        &common,
822                        false,
823                    );
824                } else {
825                    shell_println!();
826                    for m in &matches {
827                        let name = m.rsplit('/').next().unwrap_or(m);
828                        shell_print!("{}  ", name);
829                    }
830                    shell_println!();
831                    output::print_prompt();
832                    print_bytes(&input_buf[..*input_len]);
833                    let back = char_count(&input_buf[*cursor_pos..*input_len]);
834                    move_cursor_left_chars(back);
835                }
836            }
837        }
838    }
839}
840
841/// Replace the word starting at `word_start` (byte offset) with `replacement`.
842fn complete_replace_word(
843    buf: &mut [u8],
844    len: &mut usize,
845    cursor: &mut usize,
846    word_start: usize,
847    replacement: &str,
848    add_trailing_space: bool,
849) {
850    let mut new_line = alloc::string::String::new();
851    if let Ok(prefix) = core::str::from_utf8(&buf[..word_start]) {
852        new_line.push_str(prefix);
853    }
854    new_line.push_str(replacement);
855    if add_trailing_space {
856        new_line.push(' ');
857    }
858    let new_cursor = new_line.len();
859    if let Ok(suffix) = core::str::from_utf8(&buf[*cursor..*len]) {
860        new_line.push_str(suffix);
861    }
862
863    let bytes = new_line.as_bytes();
864    if bytes.len() > buf.len() {
865        return;
866    }
867
868    let old_visible = char_count(&buf[..*len]);
869    move_cursor_left_chars(char_count(&buf[..*cursor]));
870
871    buf[..bytes.len()].copy_from_slice(bytes);
872    *len = bytes.len();
873    *cursor = new_cursor;
874
875    for _ in 0..old_visible {
876        print_char(' ');
877    }
878    move_cursor_left_chars(old_visible);
879    print_bytes(&buf[..*len]);
880    let back = char_count(&buf[*cursor..*len]);
881    move_cursor_left_chars(back);
882}
883
884/// Find the longest common prefix of a set of strings.
885fn longest_common_prefix(strings: &[&str]) -> alloc::string::String {
886    if strings.is_empty() {
887        return alloc::string::String::new();
888    }
889    let first = strings[0];
890    let mut end = first.len();
891    for s in &strings[1..] {
892        end = end.min(s.len());
893        for (i, (a, b)) in first.bytes().zip(s.bytes()).enumerate() {
894            if a != b {
895                end = end.min(i);
896                break;
897            }
898        }
899    }
900    alloc::string::String::from(&first[..end])
901}
902
903/// Write captured output to a file (truncate or append).
904fn apply_redirect(redirect: &Redirect, data: &[u8]) {
905    match redirect {
906        Redirect::Truncate(path) => {
907            let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE;
908            match vfs::open(path, flags) {
909                Ok(fd) => {
910                    let _ = vfs::write(fd, data);
911                    let _ = vfs::close(fd);
912                }
913                Err(e) => shell_println!("shell: cannot write '{}': {:?}", path, e),
914            }
915        }
916        Redirect::Append(path) => {
917            let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::APPEND;
918            match vfs::open(path, flags) {
919                Ok(fd) => {
920                    let _ = vfs::write(fd, data);
921                    let _ = vfs::close(fd);
922                }
923                Err(e) => shell_println!("shell: cannot append '{}': {:?}", path, e),
924            }
925        }
926    }
927}