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