tenseleyflow/rush / 39abe95

Browse files

feat: add echo builtin, history expansion, and fix shell gaps

Major features:
- Add echo builtin with -n, -e, -E flags (bash-compatible)
- Implement history expansion (!!, !$, !^, !*, !-n, !n, !string)
- Add ~user expansion with passwd lookup in cd builtin
- Fix subshell stdin handling in pipelines (raw FD tracking)
- Add terminal attribute save/restore for proper cleanup
- Implement SIGHUP, SIGWINCH, SIGQUIT signal handling
- Add login shell support (-l flag, /etc/profile sourcing)

Bug fixes:
- Fix command substitution capture using fork-based approach
- Fix echo output capture in subprocesses (use locked stdout)
- Update CLAUDE.md to reflect Phase 5-6 complete status

New files:
- prompt.rs: PS1/PS1_RIGHT customizable prompts
- completion_spec.rs: per-command completion specifications
Authored by espadonne
SHA
39abe956d7464ec950329f6c2dd3dd32c2dbe1e9
Parents
78b7a2f
Tree
20dbf4a

18 changed files

StatusFile+-
M Cargo.toml 7 1
M crates/rush-cli/src/main.rs 17 2
M crates/rush-cli/src/repl.rs 441 11
M crates/rush-executor/src/command.rs 596 14
M crates/rush-executor/src/command_subst_exec.rs 84 56
M crates/rush-executor/src/lib.rs 1 1
M crates/rush-executor/src/pipeline.rs 47 16
M crates/rush-expand/src/context.rs 8 0
M crates/rush-interactive/Cargo.toml 6 0
M crates/rush-interactive/src/completer.rs 144 12
A crates/rush-interactive/src/completion_spec.rs 353 0
M crates/rush-interactive/src/highlighter.rs 8 1
M crates/rush-interactive/src/lib.rs 7 0
A crates/rush-interactive/src/prompt.rs 257 0
M crates/rush-job/src/job.rs 22 0
M crates/rush-job/src/lib.rs 5 2
M crates/rush-job/src/signals.rs 40 1
M crates/rush-job/src/terminal.rs 39 0
Cargo.tomlmodified
@@ -28,7 +28,7 @@ crossterm = "0.28"
2828
 nu-ansi-term = "0.50"
2929
 
3030
 # System calls (Unix)
31
-nix = { version = "0.29", features = ["process", "signal", "term", "fs", "resource"] }
31
+nix = { version = "0.29", features = ["process", "signal", "term", "fs", "resource", "user"] }
3232
 
3333
 # Pattern matching
3434
 globset = "0.4"
@@ -42,5 +42,11 @@ anyhow = "1.0"
4242
 clap = { version = "4.5", features = ["derive"] }
4343
 dirs = "5.0"
4444
 
45
+# Time/Date
46
+chrono = "0.4"
47
+
48
+# System info
49
+hostname = "0.4"
50
+
4551
 # Testing
4652
 proptest = "1.4"
crates/rush-cli/src/main.rsmodified
@@ -28,6 +28,10 @@ struct Cli {
2828
     #[arg(short = 'c', value_name = "COMMAND")]
2929
     command: Option<String>,
3030
 
31
+    /// Run as a login shell
32
+    #[arg(short = 'l', long = "login")]
33
+    login: bool,
34
+
3135
     /// Script file to execute
3236
     #[arg(value_name = "FILE")]
3337
     file: Option<String>,
@@ -69,7 +73,12 @@ fn main() -> ExitCode {
6973
             eprintln!("rush: warning: failed to set up job control: {}", e);
7074
         }
7175
 
72
-        repl::run_interactive()
76
+        // Check if this is a login shell (either -l flag or argv[0] starts with '-')
77
+        let is_login = cli.login || std::env::args().next()
78
+            .map(|arg| arg.starts_with('-'))
79
+            .unwrap_or(false);
80
+
81
+        repl::run_interactive(is_login)
7382
     } else {
7483
         // Non-interactive mode (stdin)
7584
         execute_stdin()
@@ -79,11 +88,17 @@ fn main() -> ExitCode {
7988
 /// Set up the shell for interactive use with job control
8089
 #[cfg(unix)]
8190
 fn setup_interactive_shell() -> Result<(), String> {
82
-    use rush_job::setup_shell_terminal;
91
+    use rush_job::{save_terminal_attrs, setup_job_control_signals, setup_shell_terminal};
92
+
93
+    // Save terminal attributes for later restoration
94
+    save_terminal_attrs().map_err(|e| format!("save terminal attrs: {}", e))?;
8395
 
8496
     // Put shell in its own process group and take terminal control
8597
     setup_shell_terminal().map_err(|e| e.to_string())?;
8698
 
99
+    // Set up job control signals (SIGWINCH, SIGHUP, SIGQUIT, etc.)
100
+    setup_job_control_signals().map_err(|e| format!("job control signals: {}", e))?;
101
+
87102
     Ok(())
88103
 }
89104
 
crates/rush-cli/src/repl.rsmodified
@@ -1,24 +1,389 @@
11
 use reedline::{
2
-    ColumnarMenu, DefaultPrompt, Emacs, FileBackedHistory,
2
+    ColumnarMenu, Emacs, FileBackedHistory,
33
     KeyCode, KeyModifiers, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal,
44
     default_emacs_keybindings,
55
 };
6
-use rush_interactive::{RushCompleter, RushHighlighter, RushHinter};
6
+use rush_expand::Context;
7
+use rush_interactive::{RushCompleter, RushHighlighter, RushHinter, RushPrompt};
78
 use std::process::ExitCode;
89
 
9
-pub fn run_interactive() -> ExitCode {
10
+/// Expand history references in a command line
11
+///
12
+/// Supports:
13
+/// - `!!` - previous command
14
+/// - `!$` - last argument of previous command
15
+/// - `!^` - first argument of previous command
16
+/// - `!*` - all arguments of previous command
17
+/// - `!-n` - nth previous command
18
+/// - `!n` - command at history index n
19
+/// - `!string` - most recent command starting with string
20
+fn expand_history(input: &str, history: &[String]) -> Result<String, String> {
21
+    if !input.contains('!') {
22
+        return Ok(input.to_string());
23
+    }
24
+
25
+    let mut result = String::new();
26
+    let mut chars = input.chars().peekable();
27
+    let mut in_single_quote = false;
28
+    let mut in_double_quote = false;
29
+
30
+    while let Some(c) = chars.next() {
31
+        match c {
32
+            '\'' if !in_double_quote => {
33
+                in_single_quote = !in_single_quote;
34
+                result.push(c);
35
+            }
36
+            '"' if !in_single_quote => {
37
+                in_double_quote = !in_double_quote;
38
+                result.push(c);
39
+            }
40
+            '!' if !in_single_quote => {
41
+                // Check for escaped !
42
+                if let Some(&next) = chars.peek() {
43
+                    match next {
44
+                        '!' => {
45
+                            // !! - previous command
46
+                            chars.next();
47
+                            if history.is_empty() {
48
+                                return Err("!!: event not found".to_string());
49
+                            }
50
+                            result.push_str(&history[history.len() - 1]);
51
+                        }
52
+                        '$' => {
53
+                            // !$ - last argument of previous command
54
+                            chars.next();
55
+                            if history.is_empty() {
56
+                                return Err("!$: event not found".to_string());
57
+                            }
58
+                            let prev = &history[history.len() - 1];
59
+                            let args: Vec<&str> = prev.split_whitespace().collect();
60
+                            if let Some(last) = args.last() {
61
+                                result.push_str(last);
62
+                            }
63
+                        }
64
+                        '^' => {
65
+                            // !^ - first argument of previous command
66
+                            chars.next();
67
+                            if history.is_empty() {
68
+                                return Err("!^: event not found".to_string());
69
+                            }
70
+                            let prev = &history[history.len() - 1];
71
+                            let args: Vec<&str> = prev.split_whitespace().collect();
72
+                            if args.len() > 1 {
73
+                                result.push_str(args[1]);
74
+                            }
75
+                        }
76
+                        '*' => {
77
+                            // !* - all arguments of previous command
78
+                            chars.next();
79
+                            if history.is_empty() {
80
+                                return Err("!*: event not found".to_string());
81
+                            }
82
+                            let prev = &history[history.len() - 1];
83
+                            let args: Vec<&str> = prev.split_whitespace().collect();
84
+                            if args.len() > 1 {
85
+                                result.push_str(&args[1..].join(" "));
86
+                            }
87
+                        }
88
+                        '-' => {
89
+                            // !-n - nth previous command
90
+                            chars.next();
91
+                            let mut num_str = String::new();
92
+                            while let Some(&ch) = chars.peek() {
93
+                                if ch.is_ascii_digit() {
94
+                                    num_str.push(chars.next().unwrap());
95
+                                } else {
96
+                                    break;
97
+                                }
98
+                            }
99
+                            if let Ok(n) = num_str.parse::<usize>() {
100
+                                if n > 0 && n <= history.len() {
101
+                                    result.push_str(&history[history.len() - n]);
102
+                                } else {
103
+                                    return Err(format!("!-{}: event not found", n));
104
+                                }
105
+                            } else {
106
+                                return Err(format!("!-{}: event not found", num_str));
107
+                            }
108
+                        }
109
+                        d if d.is_ascii_digit() => {
110
+                            // !n - command at index n
111
+                            let mut num_str = String::new();
112
+                            while let Some(&ch) = chars.peek() {
113
+                                if ch.is_ascii_digit() {
114
+                                    num_str.push(chars.next().unwrap());
115
+                                } else {
116
+                                    break;
117
+                                }
118
+                            }
119
+                            if let Ok(n) = num_str.parse::<usize>() {
120
+                                if n > 0 && n <= history.len() {
121
+                                    result.push_str(&history[n - 1]);
122
+                                } else {
123
+                                    return Err(format!("!{}: event not found", n));
124
+                                }
125
+                            } else {
126
+                                return Err(format!("!{}: event not found", num_str));
127
+                            }
128
+                        }
129
+                        ch if ch.is_alphanumeric() || ch == '_' => {
130
+                            // !string - most recent command starting with string
131
+                            let mut prefix = String::new();
132
+                            while let Some(&ch) = chars.peek() {
133
+                                if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
134
+                                    prefix.push(chars.next().unwrap());
135
+                                } else {
136
+                                    break;
137
+                                }
138
+                            }
139
+                            // Find most recent command starting with prefix
140
+                            if let Some(cmd) = history.iter().rev().find(|h| h.starts_with(&prefix)) {
141
+                                result.push_str(cmd);
142
+                            } else {
143
+                                return Err(format!("!{}: event not found", prefix));
144
+                            }
145
+                        }
146
+                        ' ' | '\t' | '\n' => {
147
+                            // Standalone ! followed by whitespace - literal
148
+                            result.push('!');
149
+                        }
150
+                        _ => {
151
+                            // Unknown ! sequence - keep as is
152
+                            result.push('!');
153
+                        }
154
+                    }
155
+                } else {
156
+                    // ! at end of line - literal
157
+                    result.push('!');
158
+                }
159
+            }
160
+            _ => {
161
+                result.push(c);
162
+            }
163
+        }
164
+    }
165
+
166
+    Ok(result)
167
+}
168
+
169
+/// Get history as a vector of strings from reedline history
170
+fn get_history_strings(line_editor: &Reedline) -> Vec<String> {
171
+    let history = line_editor.history();
172
+
173
+    // Try to get all history items (no session filtering)
174
+    match history.search(reedline::SearchQuery::last_with_search(
175
+        reedline::SearchFilter::anything(None),
176
+    )) {
177
+        Ok(items) => items.into_iter().map(|item| item.command_line).collect(),
178
+        Err(_) => Vec::new(),
179
+    }
180
+}
181
+
182
+/// Default config file contents
183
+const DEFAULT_CONFIG: &str = r#"# Rush shell configuration
184
+# This file is sourced on interactive shell startup
185
+
186
+# Example aliases
187
+# alias ll='ls -la'
188
+# alias la='ls -A'
189
+
190
+# Example prompt customization (uncomment to use)
191
+# export PS1='\u@\h:\w\$ '
192
+# export PS1_RIGHT='\D{%m/%d/%Y %I:%M:%S %p}'
193
+
194
+# Example environment variables
195
+# export EDITOR=vim
196
+"#;
197
+
198
+/// Check if a config file exists
199
+fn config_exists() -> bool {
200
+    let config_paths = [
201
+        dirs::home_dir().map(|p| p.join(".rushrc")),
202
+        dirs::config_dir().map(|p| p.join("rush").join("rushrc")),
203
+    ];
204
+
205
+    config_paths.iter().flatten().any(|p| p.exists())
206
+}
207
+
208
+/// Prompt user to create default config on first run
209
+fn maybe_create_default_config() {
210
+    if config_exists() {
211
+        return;
212
+    }
213
+
214
+    // Check if we've already asked (store a marker file)
215
+    let marker_path = dirs::data_dir().map(|p| p.join("rush").join(".config_prompted"));
216
+    if let Some(ref marker) = marker_path {
217
+        if marker.exists() {
218
+            return;
219
+        }
220
+    }
221
+
222
+    println!("Welcome to Rush! No configuration file found.");
223
+    print!("Create default config at ~/.rushrc? [Y/n] ");
224
+
225
+    // Flush stdout to ensure prompt is displayed
226
+    use std::io::Write;
227
+    std::io::stdout().flush().ok();
228
+
229
+    let mut input = String::new();
230
+    if std::io::stdin().read_line(&mut input).is_ok() {
231
+        let response = input.trim().to_lowercase();
232
+        if response.is_empty() || response == "y" || response == "yes" {
233
+            // Create the config file
234
+            if let Some(config_path) = dirs::home_dir().map(|p| p.join(".rushrc")) {
235
+                match std::fs::write(&config_path, DEFAULT_CONFIG) {
236
+                    Ok(_) => println!("Created {}. Edit it to customize your shell!", config_path.display()),
237
+                    Err(e) => eprintln!("rush: could not create config: {}", e),
238
+                }
239
+            }
240
+        } else {
241
+            println!("Skipped. You can create ~/.rushrc manually anytime.");
242
+        }
243
+    }
244
+
245
+    // Create marker so we don't ask again
246
+    if let Some(marker) = marker_path {
247
+        if let Some(parent) = marker.parent() {
248
+            std::fs::create_dir_all(parent).ok();
249
+        }
250
+        std::fs::write(marker, "").ok();
251
+    }
252
+
253
+    println!();
254
+}
255
+
256
+/// Source config files on interactive shell startup
257
+fn source_config_files(context: &mut Context) {
258
+    let config_paths = [
259
+        dirs::home_dir().map(|p| p.join(".rushrc")),
260
+        dirs::config_dir().map(|p| p.join("rush").join("rushrc")),
261
+    ];
262
+
263
+    for path_opt in config_paths {
264
+        if let Some(path) = path_opt {
265
+            if path.exists() {
266
+                match std::fs::read_to_string(&path) {
267
+                    Ok(content) => {
268
+                        // Filter out comment-only and empty lines, then parse
269
+                        let executable_lines: Vec<&str> = content
270
+                            .lines()
271
+                            .filter(|line| {
272
+                                let trimmed = line.trim();
273
+                                !trimmed.is_empty() && !trimmed.starts_with('#')
274
+                            })
275
+                            .collect();
276
+
277
+                        // Skip if no executable content
278
+                        if executable_lines.is_empty() {
279
+                            break;
280
+                        }
281
+
282
+                        let filtered_content = executable_lines.join("\n");
283
+
284
+                        // Parse and execute the config file
285
+                        use rush_parser::parse_line;
286
+                        match parse_line(&filtered_content) {
287
+                            Ok(statement) => {
288
+                                if let Err(e) = rush_executor::execute_statement(&statement, context) {
289
+                                    eprintln!("rush: error in {:?}: {}", path, e);
290
+                                }
291
+                            }
292
+                            Err(e) => {
293
+                                eprintln!("rush: parse error in {:?}: {}", path, e);
294
+                            }
295
+                        }
296
+                    }
297
+                    Err(e) => {
298
+                        eprintln!("rush: could not read {:?}: {}", path, e);
299
+                    }
300
+                }
301
+                break; // Only source first found config
302
+            }
303
+        }
304
+    }
305
+}
306
+
307
+/// Source login shell profile files
308
+fn source_login_profiles(context: &mut Context) {
309
+    let profile_paths = [
310
+        // System-wide profile
311
+        Some(std::path::PathBuf::from("/etc/profile")),
312
+        // User profile (traditional)
313
+        dirs::home_dir().map(|p| p.join(".profile")),
314
+        // Rush-specific login profile
315
+        dirs::home_dir().map(|p| p.join(".rush_profile")),
316
+    ];
317
+
318
+    for path_opt in profile_paths {
319
+        if let Some(path) = path_opt {
320
+            if path.exists() {
321
+                match std::fs::read_to_string(&path) {
322
+                    Ok(content) => {
323
+                        // Filter comments and empty lines
324
+                        let executable_lines: Vec<&str> = content
325
+                            .lines()
326
+                            .filter(|line| {
327
+                                let trimmed = line.trim();
328
+                                !trimmed.is_empty() && !trimmed.starts_with('#')
329
+                            })
330
+                            .collect();
331
+
332
+                        if executable_lines.is_empty() {
333
+                            continue;
334
+                        }
335
+
336
+                        let filtered_content = executable_lines.join("\n");
337
+
338
+                        use rush_parser::parse_line;
339
+                        match parse_line(&filtered_content) {
340
+                            Ok(statement) => {
341
+                                if let Err(e) = rush_executor::execute_statement(&statement, context) {
342
+                                    eprintln!("rush: error in {:?}: {}", path, e);
343
+                                }
344
+                            }
345
+                            Err(e) => {
346
+                                eprintln!("rush: parse error in {:?}: {}", path, e);
347
+                            }
348
+                        }
349
+                    }
350
+                    Err(e) => {
351
+                        // Only warn for user profiles, not system ones
352
+                        if path.starts_with(dirs::home_dir().unwrap_or_default()) {
353
+                            eprintln!("rush: could not read {:?}: {}", path, e);
354
+                        }
355
+                    }
356
+                }
357
+            }
358
+        }
359
+    }
360
+}
361
+
362
+pub fn run_interactive(is_login: bool) -> ExitCode {
363
+    // On first run, offer to create default config
364
+    maybe_create_default_config();
365
+
10366
     // Set up persistent history
11
-    let history_file = dirs::data_dir()
367
+    let history_path = dirs::data_dir()
12368
         .map(|mut path| {
13369
             path.push("rush");
14
-            std::fs::create_dir_all(&path).ok();
370
+            if let Err(e) = std::fs::create_dir_all(&path) {
371
+                eprintln!("rush: warning: could not create data directory: {}", e);
372
+            }
15373
             path.push("history.txt");
16374
             path
17
-        })
18
-        .and_then(|path| {
19
-            FileBackedHistory::with_file(1000, path).ok()
20375
         });
21376
 
377
+    let history_file = history_path.as_ref().and_then(|path| {
378
+        match FileBackedHistory::with_file(1000, path.clone()) {
379
+            Ok(history) => Some(history),
380
+            Err(e) => {
381
+                eprintln!("rush: warning: could not load history from {:?}: {}", path, e);
382
+                None
383
+            }
384
+        }
385
+    });
386
+
22387
     // Create the completion menu with custom marker (instead of default "|")
23388
     let completion_menu = Box::new(
24389
         ColumnarMenu::default()
@@ -100,10 +465,26 @@ pub fn run_interactive() -> ExitCode {
100465
         line_editor = line_editor.with_history(Box::new(history));
101466
     }
102467
 
103
-    let prompt = DefaultPrompt::default();
468
+    let prompt = RushPrompt::new();
104469
     let mut context = crate::create_context();
105470
 
471
+    // Source login profile files if this is a login shell
472
+    if is_login {
473
+        source_login_profiles(&mut context);
474
+    }
475
+
476
+    // Source config files (~/.rushrc or ~/.config/rush/rushrc)
477
+    source_config_files(&mut context);
478
+
106479
     loop {
480
+        // Check for SIGHUP (terminal hangup)
481
+        #[cfg(unix)]
482
+        if rush_job::check_sighup() {
483
+            // Send SIGHUP to all jobs and exit
484
+            context.job_list.send_hup_to_all();
485
+            break;
486
+        }
487
+
107488
         // Check for completed/stopped background jobs before each prompt
108489
         #[cfg(unix)]
109490
         crate::check_background_jobs(&mut context);
@@ -116,19 +497,68 @@ pub fn run_interactive() -> ExitCode {
116497
                     continue;
117498
                 }
118499
 
119
-                if let Err(e) = crate::execute_interactive_line(&buffer, &mut context) {
500
+                // Perform history expansion if the line contains !
501
+                let expanded_buffer = if buffer.contains('!') {
502
+                    let history = get_history_strings(&line_editor);
503
+                    match expand_history(&buffer, &history) {
504
+                        Ok(expanded) => {
505
+                            // If expansion changed the line, print it (bash behavior)
506
+                            if expanded != buffer {
507
+                                println!("{}", expanded);
508
+                            }
509
+                            expanded
510
+                        }
511
+                        Err(e) => {
512
+                            eprintln!("rush: {}", e);
513
+                            continue;
514
+                        }
515
+                    }
516
+                } else {
517
+                    buffer
518
+                };
519
+
520
+                if let Err(e) = crate::execute_interactive_line(&expanded_buffer, &mut context) {
120521
                     eprintln!("rush: {}", e);
121522
                 }
523
+
524
+                // Check if exit was requested
525
+                if context.exit_requested.is_some() {
526
+                    break;
527
+                }
122528
             }
123529
             Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => {
124530
                 break;
125531
             }
126532
             Err(e) => {
127533
                 eprintln!("rush: error: {}", e);
534
+                // Sync history before returning on error
535
+                if let Err(e) = line_editor.sync_history() {
536
+                    eprintln!("rush: warning: failed to save history: {}", e);
537
+                }
538
+                // Restore terminal attributes
539
+                #[cfg(unix)]
540
+                if let Err(e) = rush_job::restore_terminal_attrs() {
541
+                    eprintln!("rush: warning: failed to restore terminal: {}", e);
542
+                }
128543
                 return ExitCode::from(1);
129544
             }
130545
         }
131546
     }
132547
 
133
-    ExitCode::SUCCESS
548
+    // Sync history before exiting
549
+    if let Err(e) = line_editor.sync_history() {
550
+        eprintln!("rush: warning: failed to save history: {}", e);
551
+    }
552
+
553
+    // Restore terminal attributes to their original state
554
+    #[cfg(unix)]
555
+    if let Err(e) = rush_job::restore_terminal_attrs() {
556
+        eprintln!("rush: warning: failed to restore terminal: {}", e);
557
+    }
558
+
559
+    // Return requested exit code, or SUCCESS
560
+    match context.exit_requested {
561
+        Some(code) => ExitCode::from(code as u8),
562
+        None => ExitCode::SUCCESS,
563
+    }
134564
 }
crates/rush-executor/src/command.rsmodified
@@ -126,23 +126,14 @@ pub(crate) fn execute_builtin(
126126
         "exit" => {
127127
             let code = args.first()
128128
                 .and_then(|s| s.parse::<i32>().ok())
129
-                .unwrap_or(0);
130
-            std::process::exit(code);
129
+                .unwrap_or(context.last_exit_status);
130
+            context.exit_requested = Some(code);
131
+            Some(exit_code_to_result(code))
131132
         }
132133
         "true" => Some(success_result()),
133134
         "false" => Some(error_result()),
134135
         ":" => Some(success_result()),
135
-        "cd" => {
136
-            let default_home = env::var("HOME").unwrap_or_else(|_| "/".to_string());
137
-            let dir = args.first()
138
-                .map(|s| s.as_str())
139
-                .unwrap_or(&default_home);
140
-
141
-            match env::set_current_dir(dir) {
142
-                Ok(_) => Some(success_result()),
143
-                Err(_) => Some(error_result()),
144
-            }
145
-        }
136
+        "cd" => Some(builtin_cd(args, context)),
146137
         "pwd" => {
147138
             match env::current_dir() {
148139
                 Ok(path) => {
@@ -225,8 +216,13 @@ pub(crate) fn execute_builtin(
225216
             eprintln!("{}: job control not supported on this platform", command);
226217
             Some(error_result())
227218
         }
219
+        "echo" => Some(builtin_echo(args)),
228220
         "printf" => Some(builtin_printf(args, context)),
229221
         "mapfile" | "readarray" => Some(builtin_mapfile(args, context)),
222
+        "complete" => Some(builtin_complete(args, context)),
223
+        "pushd" => Some(builtin_pushd(args, context)),
224
+        "popd" => Some(builtin_popd(args, context)),
225
+        "dirs" => Some(builtin_dirs(args, context)),
230226
         _ => None,
231227
     }
232228
 }
@@ -2267,6 +2263,364 @@ fn builtin_eval(args: &[String], context: &mut rush_expand::Context) -> Result<E
22672263
     }
22682264
 }
22692265
 
2266
+/// cd builtin - change directory
2267
+/// Supports: cd, cd -, cd ~, cd ~user, cd /path
2268
+fn builtin_cd(args: &[String], context: &mut rush_expand::Context) -> ExecutionResult {
2269
+    let home = env::var("HOME").unwrap_or_else(|_| "/".to_string());
2270
+
2271
+    // Get target directory
2272
+    let target = if args.is_empty() {
2273
+        // cd with no args goes to $HOME
2274
+        home.clone()
2275
+    } else {
2276
+        let arg = &args[0];
2277
+        if arg == "-" {
2278
+            // cd - goes to $OLDPWD
2279
+            match context.get_var("OLDPWD") {
2280
+                Some(oldpwd) => {
2281
+                    println!("{}", oldpwd);
2282
+                    oldpwd.to_string()
2283
+                }
2284
+                None => {
2285
+                    eprintln!("cd: OLDPWD not set");
2286
+                    return error_result();
2287
+                }
2288
+            }
2289
+        } else if arg == "~" || arg.is_empty() {
2290
+            // cd ~ goes to $HOME
2291
+            home.clone()
2292
+        } else if arg.starts_with("~/") {
2293
+            // cd ~/path goes to $HOME/path
2294
+            format!("{}/{}", home, &arg[2..])
2295
+        } else if arg.starts_with('~') {
2296
+            // cd ~user - lookup user's home directory
2297
+            let (username, suffix) = if let Some(slash_pos) = arg.find('/') {
2298
+                (&arg[1..slash_pos], &arg[slash_pos..])
2299
+            } else {
2300
+                (&arg[1..], "")
2301
+            };
2302
+
2303
+            #[cfg(unix)]
2304
+            {
2305
+                use nix::unistd::User;
2306
+                match User::from_name(username) {
2307
+                    Ok(Some(user)) => {
2308
+                        let home = user.dir.to_string_lossy();
2309
+                        if suffix.is_empty() {
2310
+                            home.to_string()
2311
+                        } else {
2312
+                            format!("{}{}", home, suffix)
2313
+                        }
2314
+                    }
2315
+                    Ok(None) => {
2316
+                        eprintln!("cd: ~{}: No such user", username);
2317
+                        return error_result();
2318
+                    }
2319
+                    Err(e) => {
2320
+                        eprintln!("cd: ~{}: {}", username, e);
2321
+                        return error_result();
2322
+                    }
2323
+                }
2324
+            }
2325
+            #[cfg(not(unix))]
2326
+            {
2327
+                // On non-Unix platforms, just treat as literal path
2328
+                arg.clone()
2329
+            }
2330
+        } else {
2331
+            arg.clone()
2332
+        }
2333
+    };
2334
+
2335
+    // Save current directory as OLDPWD before changing
2336
+    if let Ok(cwd) = env::current_dir() {
2337
+        let _ = context.set_var("OLDPWD", cwd.to_string_lossy().to_string());
2338
+    }
2339
+
2340
+    // Change directory
2341
+    match env::set_current_dir(&target) {
2342
+        Ok(_) => {
2343
+            // Update PWD
2344
+            if let Ok(new_cwd) = env::current_dir() {
2345
+                let _ = context.set_var("PWD", new_cwd.to_string_lossy().to_string());
2346
+            }
2347
+            success_result()
2348
+        }
2349
+        Err(e) => {
2350
+            eprintln!("cd: {}: {}", target, e);
2351
+            error_result()
2352
+        }
2353
+    }
2354
+}
2355
+
2356
+/// pushd builtin - push directory onto stack and cd to it
2357
+fn builtin_pushd(args: &[String], context: &mut rush_expand::Context) -> ExecutionResult {
2358
+    // Get current directory first
2359
+    let cwd = match env::current_dir() {
2360
+        Ok(p) => p.to_string_lossy().to_string(),
2361
+        Err(e) => {
2362
+            eprintln!("pushd: error getting current directory: {}", e);
2363
+            return error_result();
2364
+        }
2365
+    };
2366
+
2367
+    if args.is_empty() {
2368
+        // pushd with no args swaps top two directories
2369
+        if context.dir_stack.is_empty() {
2370
+            eprintln!("pushd: no other directory");
2371
+            return error_result();
2372
+        }
2373
+
2374
+        let top = context.dir_stack.remove(0);
2375
+        context.dir_stack.insert(0, cwd.clone());
2376
+
2377
+        // Change to the popped directory
2378
+        if let Err(e) = env::set_current_dir(&top) {
2379
+            // Restore stack on failure
2380
+            context.dir_stack.remove(0);
2381
+            context.dir_stack.insert(0, top);
2382
+            eprintln!("pushd: {}", e);
2383
+            return error_result();
2384
+        }
2385
+
2386
+        // Update OLDPWD and PWD
2387
+        let _ = context.set_var("OLDPWD", cwd);
2388
+        let _ = context.set_var("PWD", top.clone());
2389
+
2390
+        // Print the stack
2391
+        print_dir_stack(context);
2392
+        success_result()
2393
+    } else {
2394
+        let target = &args[0];
2395
+
2396
+        // Expand ~ in target
2397
+        let expanded = if target == "~" {
2398
+            env::var("HOME").unwrap_or_else(|_| "/".to_string())
2399
+        } else if target.starts_with("~/") {
2400
+            let home = env::var("HOME").unwrap_or_else(|_| "/".to_string());
2401
+            format!("{}/{}", home, &target[2..])
2402
+        } else {
2403
+            target.clone()
2404
+        };
2405
+
2406
+        // Push current directory onto stack
2407
+        context.dir_stack.insert(0, cwd.clone());
2408
+
2409
+        // Change to new directory
2410
+        if let Err(e) = env::set_current_dir(&expanded) {
2411
+            // Remove the directory we just pushed on failure
2412
+            context.dir_stack.remove(0);
2413
+            eprintln!("pushd: {}: {}", expanded, e);
2414
+            return error_result();
2415
+        }
2416
+
2417
+        // Update OLDPWD and PWD
2418
+        let _ = context.set_var("OLDPWD", cwd);
2419
+        if let Ok(new_cwd) = env::current_dir() {
2420
+            let _ = context.set_var("PWD", new_cwd.to_string_lossy().to_string());
2421
+        }
2422
+
2423
+        // Print the stack
2424
+        print_dir_stack(context);
2425
+        success_result()
2426
+    }
2427
+}
2428
+
2429
+/// popd builtin - pop directory from stack and cd to it
2430
+fn builtin_popd(args: &[String], context: &mut rush_expand::Context) -> ExecutionResult {
2431
+    // Check for -n flag (don't change directory, just manipulate stack)
2432
+    let no_cd = args.iter().any(|a| a == "-n");
2433
+
2434
+    if context.dir_stack.is_empty() {
2435
+        eprintln!("popd: directory stack empty");
2436
+        return error_result();
2437
+    }
2438
+
2439
+    let dir = context.dir_stack.remove(0);
2440
+
2441
+    if !no_cd {
2442
+        // Save current directory as OLDPWD
2443
+        if let Ok(cwd) = env::current_dir() {
2444
+            let _ = context.set_var("OLDPWD", cwd.to_string_lossy().to_string());
2445
+        }
2446
+
2447
+        // Change to the popped directory
2448
+        if let Err(e) = env::set_current_dir(&dir) {
2449
+            // Re-add directory to stack on failure
2450
+            context.dir_stack.insert(0, dir);
2451
+            eprintln!("popd: {}", e);
2452
+            return error_result();
2453
+        }
2454
+
2455
+        // Update PWD
2456
+        if let Ok(new_cwd) = env::current_dir() {
2457
+            let _ = context.set_var("PWD", new_cwd.to_string_lossy().to_string());
2458
+        }
2459
+    }
2460
+
2461
+    // Print the stack
2462
+    print_dir_stack(context);
2463
+    success_result()
2464
+}
2465
+
2466
+/// dirs builtin - display directory stack
2467
+fn builtin_dirs(args: &[String], context: &mut rush_expand::Context) -> ExecutionResult {
2468
+    let clear = args.iter().any(|a| a == "-c");
2469
+    let print_index = args.iter().any(|a| a == "-v");
2470
+    let one_per_line = args.iter().any(|a| a == "-p");
2471
+
2472
+    if clear {
2473
+        context.dir_stack.clear();
2474
+        return success_result();
2475
+    }
2476
+
2477
+    // Get current directory
2478
+    let cwd = env::current_dir()
2479
+        .map(|p| p.to_string_lossy().to_string())
2480
+        .unwrap_or_else(|_| ".".to_string());
2481
+
2482
+    if print_index {
2483
+        // Print with indices
2484
+        println!(" 0  {}", cwd);
2485
+        for (i, dir) in context.dir_stack.iter().enumerate() {
2486
+            println!(" {}  {}", i + 1, dir);
2487
+        }
2488
+    } else if one_per_line {
2489
+        // One directory per line
2490
+        println!("{}", cwd);
2491
+        for dir in &context.dir_stack {
2492
+            println!("{}", dir);
2493
+        }
2494
+    } else {
2495
+        // Default: space-separated on one line
2496
+        print_dir_stack(context);
2497
+    }
2498
+
2499
+    success_result()
2500
+}
2501
+
2502
+/// Helper to print the directory stack
2503
+fn print_dir_stack(context: &rush_expand::Context) {
2504
+    let cwd = env::current_dir()
2505
+        .map(|p| p.to_string_lossy().to_string())
2506
+        .unwrap_or_else(|_| ".".to_string());
2507
+
2508
+    let mut parts = vec![cwd];
2509
+    parts.extend(context.dir_stack.iter().cloned());
2510
+    println!("{}", parts.join(" "));
2511
+}
2512
+
2513
+/// echo builtin - display a line of text
2514
+///
2515
+/// Uses direct writes to stdout for reliable capture in command substitution
2516
+fn builtin_echo(args: &[String]) -> ExecutionResult {
2517
+    use std::io::Write;
2518
+
2519
+    let mut newline = true;
2520
+    let mut interpret_escapes = false;
2521
+    let mut args_iter = args.iter().peekable();
2522
+
2523
+    // Parse options (bash-style: only at the start, stop at first non-option)
2524
+    while let Some(arg) = args_iter.peek() {
2525
+        if arg.starts_with('-') && arg.len() > 1 && arg.chars().skip(1).all(|c| matches!(c, 'n' | 'e' | 'E')) {
2526
+            let arg = args_iter.next().unwrap();
2527
+            for c in arg.chars().skip(1) {
2528
+                match c {
2529
+                    'n' => newline = false,
2530
+                    'e' => interpret_escapes = true,
2531
+                    'E' => interpret_escapes = false,
2532
+                    _ => {}
2533
+                }
2534
+            }
2535
+        } else {
2536
+            break;
2537
+        }
2538
+    }
2539
+
2540
+    let remaining: Vec<&String> = args_iter.collect();
2541
+    let text = remaining.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ");
2542
+
2543
+    // Get stdout handle and lock it for the duration of the write
2544
+    let stdout = std::io::stdout();
2545
+    let mut handle = stdout.lock();
2546
+
2547
+    if interpret_escapes {
2548
+        let mut output = String::new();
2549
+        let mut chars = text.chars().peekable();
2550
+        while let Some(c) = chars.next() {
2551
+            if c == '\\' {
2552
+                match chars.next() {
2553
+                    Some('n') => output.push('\n'),
2554
+                    Some('t') => output.push('\t'),
2555
+                    Some('r') => output.push('\r'),
2556
+                    Some('\\') => output.push('\\'),
2557
+                    Some('a') => output.push('\x07'),
2558
+                    Some('b') => output.push('\x08'),
2559
+                    Some('f') => output.push('\x0C'),
2560
+                    Some('v') => output.push('\x0B'),
2561
+                    Some('0') => {
2562
+                        // Octal escape
2563
+                        let mut oct = String::new();
2564
+                        for _ in 0..3 {
2565
+                            if let Some(&ch) = chars.peek() {
2566
+                                if ch >= '0' && ch <= '7' {
2567
+                                    oct.push(chars.next().unwrap());
2568
+                                } else {
2569
+                                    break;
2570
+                                }
2571
+                            }
2572
+                        }
2573
+                        let val = u8::from_str_radix(&oct, 8).unwrap_or(0);
2574
+                        output.push(val as char);
2575
+                    }
2576
+                    Some('x') => {
2577
+                        // Hex escape
2578
+                        let mut hex = String::new();
2579
+                        for _ in 0..2 {
2580
+                            if let Some(&ch) = chars.peek() {
2581
+                                if ch.is_ascii_hexdigit() {
2582
+                                    hex.push(chars.next().unwrap());
2583
+                                } else {
2584
+                                    break;
2585
+                                }
2586
+                            }
2587
+                        }
2588
+                        if !hex.is_empty() {
2589
+                            let val = u8::from_str_radix(&hex, 16).unwrap_or(0);
2590
+                            output.push(val as char);
2591
+                        } else {
2592
+                            output.push_str("\\x");
2593
+                        }
2594
+                    }
2595
+                    Some('c') => {
2596
+                        // Stop output (no newline either)
2597
+                        let _ = handle.write_all(output.as_bytes());
2598
+                        let _ = handle.flush();
2599
+                        return success_result();
2600
+                    }
2601
+                    Some(other) => {
2602
+                        output.push('\\');
2603
+                        output.push(other);
2604
+                    }
2605
+                    None => output.push('\\'),
2606
+                }
2607
+            } else {
2608
+                output.push(c);
2609
+            }
2610
+        }
2611
+        let _ = handle.write_all(output.as_bytes());
2612
+    } else {
2613
+        let _ = handle.write_all(text.as_bytes());
2614
+    }
2615
+
2616
+    if newline {
2617
+        let _ = handle.write_all(b"\n");
2618
+    }
2619
+
2620
+    let _ = handle.flush();
2621
+    success_result()
2622
+}
2623
+
22702624
 /// printf builtin - formatted output
22712625
 fn builtin_printf(args: &[String], _context: &mut rush_expand::Context) -> ExecutionResult {
22722626
     if args.is_empty() {
@@ -2681,7 +3035,7 @@ fn builtin_disown(args: &[String], context: &mut rush_expand::Context) -> Execut
26813035
 }
26823036
 
26833037
 /// Helper to execute a parsed statement
2684
-fn execute_statement(
3038
+pub fn execute_statement(
26853039
     statement: &rush_parser::Statement,
26863040
     context: &mut rush_expand::Context,
26873041
 ) -> Result<ExecutionResult, String> {
@@ -2705,6 +3059,234 @@ fn execute_statement(
27053059
     }
27063060
 }
27073061
 
3062
+/// `complete` builtin - Define command-specific completions
3063
+///
3064
+/// Usage:
3065
+///   complete -c COMMAND [-a COMPLETIONS] [-d DESC] [-s SHORT] [-l LONG] [-f] [-n COND]
3066
+///   complete -c COMMAND -e    # Erase completions
3067
+///   complete -p               # Print all completions
3068
+///   complete -c COMMAND -p    # Print completions for COMMAND
3069
+///
3070
+/// Options:
3071
+///   -c COMMAND    The command to add completions for
3072
+///   -a ARGS       Completions to add (space-separated, or command in parentheses)
3073
+///   -d DESC       Description for the completion
3074
+///   -s SHORT      Short option (e.g., -v)
3075
+///   -l LONG       Long option (e.g., --verbose)
3076
+///   -f            Disable file completion for this command
3077
+///   -n COND       Condition for when this completion applies
3078
+///   -e            Erase all completions for the command
3079
+///   -p            Print completions
3080
+fn builtin_complete(args: &[String], _context: &rush_expand::Context) -> ExecutionResult {
3081
+    use rush_interactive::{
3082
+        add_completion, remove_completions, with_registry,
3083
+        CompletionSource, CompletionSpec,
3084
+    };
3085
+
3086
+    let mut command: Option<String> = None;
3087
+    let mut completions: Option<String> = None;
3088
+    let mut description: Option<String> = None;
3089
+    let mut short_opt: Option<char> = None;
3090
+    let mut long_opt: Option<String> = None;
3091
+    let mut no_files = false;
3092
+    let mut condition: Option<String> = None;
3093
+    let mut erase = false;
3094
+    let mut print = false;
3095
+
3096
+    // Parse arguments
3097
+    let mut i = 0;
3098
+    while i < args.len() {
3099
+        let arg = &args[i];
3100
+        match arg.as_str() {
3101
+            "-c" => {
3102
+                i += 1;
3103
+                if i < args.len() {
3104
+                    command = Some(args[i].clone());
3105
+                } else {
3106
+                    eprintln!("complete: -c requires an argument");
3107
+                    return error_result();
3108
+                }
3109
+            }
3110
+            "-a" => {
3111
+                i += 1;
3112
+                if i < args.len() {
3113
+                    completions = Some(args[i].clone());
3114
+                } else {
3115
+                    eprintln!("complete: -a requires an argument");
3116
+                    return error_result();
3117
+                }
3118
+            }
3119
+            "-d" => {
3120
+                i += 1;
3121
+                if i < args.len() {
3122
+                    description = Some(args[i].clone());
3123
+                } else {
3124
+                    eprintln!("complete: -d requires an argument");
3125
+                    return error_result();
3126
+                }
3127
+            }
3128
+            "-s" => {
3129
+                i += 1;
3130
+                if i < args.len() {
3131
+                    short_opt = args[i].chars().next();
3132
+                } else {
3133
+                    eprintln!("complete: -s requires an argument");
3134
+                    return error_result();
3135
+                }
3136
+            }
3137
+            "-l" => {
3138
+                i += 1;
3139
+                if i < args.len() {
3140
+                    long_opt = Some(args[i].clone());
3141
+                } else {
3142
+                    eprintln!("complete: -l requires an argument");
3143
+                    return error_result();
3144
+                }
3145
+            }
3146
+            "-n" => {
3147
+                i += 1;
3148
+                if i < args.len() {
3149
+                    condition = Some(args[i].clone());
3150
+                } else {
3151
+                    eprintln!("complete: -n requires an argument");
3152
+                    return error_result();
3153
+                }
3154
+            }
3155
+            "-f" => no_files = true,
3156
+            "-e" => erase = true,
3157
+            "-p" => print = true,
3158
+            _ => {
3159
+                eprintln!("complete: unknown option: {}", arg);
3160
+                return error_result();
3161
+            }
3162
+        }
3163
+        i += 1;
3164
+    }
3165
+
3166
+    // Handle print mode
3167
+    if print {
3168
+        with_registry(|registry| {
3169
+            if let Some(cmd) = &command {
3170
+                // Print completions for specific command
3171
+                if let Some(specs) = registry.get(cmd) {
3172
+                    for spec in specs {
3173
+                        print_completion_spec(spec);
3174
+                    }
3175
+                }
3176
+            } else {
3177
+                // Print all completions
3178
+                for (cmd, specs) in registry.all_specs() {
3179
+                    println!("# Completions for '{}'", cmd);
3180
+                    for spec in specs {
3181
+                        print_completion_spec(spec);
3182
+                    }
3183
+                }
3184
+            }
3185
+        });
3186
+        return success_result();
3187
+    }
3188
+
3189
+    // Handle erase mode
3190
+    if erase {
3191
+        if let Some(cmd) = command {
3192
+            remove_completions(&cmd);
3193
+            return success_result();
3194
+        } else {
3195
+            eprintln!("complete: -e requires -c COMMAND");
3196
+            return error_result();
3197
+        }
3198
+    }
3199
+
3200
+    // Adding a completion requires a command
3201
+    let cmd = match command {
3202
+        Some(c) => c,
3203
+        None => {
3204
+            eprintln!("complete: -c COMMAND is required");
3205
+            return error_result();
3206
+        }
3207
+    };
3208
+
3209
+    // Determine the completion source
3210
+    let source = if let Some(comps) = completions {
3211
+        // Check if it's a dynamic command (wrapped in parentheses)
3212
+        if comps.starts_with('(') && comps.ends_with(')') {
3213
+            CompletionSource::Dynamic(comps[1..comps.len()-1].to_string())
3214
+        } else {
3215
+            // Static list of completions (space-separated)
3216
+            CompletionSource::Static(
3217
+                comps.split_whitespace().map(String::from).collect()
3218
+            )
3219
+        }
3220
+    } else if short_opt.is_some() || long_opt.is_some() {
3221
+        CompletionSource::Option {
3222
+            short: short_opt,
3223
+            long: long_opt,
3224
+        }
3225
+    } else if no_files {
3226
+        // Just marking no-files without adding completions
3227
+        CompletionSource::Static(vec![])
3228
+    } else {
3229
+        eprintln!("complete: need -a, -s, -l, or -f");
3230
+        return error_result();
3231
+    };
3232
+
3233
+    // Create and register the completion spec
3234
+    let spec = CompletionSpec {
3235
+        command: cmd,
3236
+        condition,
3237
+        source,
3238
+        description,
3239
+        no_files,
3240
+    };
3241
+
3242
+    add_completion(spec);
3243
+    success_result()
3244
+}
3245
+
3246
+fn print_completion_spec(spec: &rush_interactive::CompletionSpec) {
3247
+    use rush_interactive::CompletionSource;
3248
+
3249
+    let mut parts = vec![format!("complete -c {}", spec.command)];
3250
+
3251
+    if let Some(ref cond) = spec.condition {
3252
+        parts.push(format!("-n \"{}\"", cond));
3253
+    }
3254
+
3255
+    match &spec.source {
3256
+        CompletionSource::Static(items) if !items.is_empty() => {
3257
+            parts.push(format!("-a \"{}\"", items.join(" ")));
3258
+        }
3259
+        CompletionSource::Dynamic(cmd) => {
3260
+            parts.push(format!("-a \"({})\"", cmd));
3261
+        }
3262
+        CompletionSource::ShortOption(c) => {
3263
+            parts.push(format!("-s {}", c));
3264
+        }
3265
+        CompletionSource::LongOption(s) => {
3266
+            parts.push(format!("-l {}", s));
3267
+        }
3268
+        CompletionSource::Option { short, long } => {
3269
+            if let Some(c) = short {
3270
+                parts.push(format!("-s {}", c));
3271
+            }
3272
+            if let Some(l) = long {
3273
+                parts.push(format!("-l {}", l));
3274
+            }
3275
+        }
3276
+        _ => {}
3277
+    }
3278
+
3279
+    if let Some(ref desc) = spec.description {
3280
+        parts.push(format!("-d \"{}\"", desc));
3281
+    }
3282
+
3283
+    if spec.no_files {
3284
+        parts.push("-f".to_string());
3285
+    }
3286
+
3287
+    println!("{}", parts.join(" "));
3288
+}
3289
+
27083290
 #[cfg(test)]
27093291
 mod tests {
27103292
     use super::*;
crates/rush-executor/src/command_subst_exec.rsmodified
@@ -4,7 +4,6 @@
44
 //! enabling $(command) substitution without relying on external shells.
55
 
66
 use rush_expand::Context;
7
-use std::io::Write;
87
 
98
 /// Execute a command string internally and capture its stdout
109
 ///
@@ -13,18 +12,18 @@ use std::io::Write;
1312
 ///
1413
 /// The function:
1514
 /// 1. Parses the command string using rush-parser
16
-/// 2. Captures stdout to a buffer
17
-/// 3. Executes the command using rush-executor
18
-/// 4. Returns the captured output
15
+/// 2. Forks a child process
16
+/// 3. The child executes the command with stdout redirected to a pipe
17
+/// 4. The parent reads and returns the captured output
1918
 pub fn execute_for_substitution(cmd: &str, context: &mut Context) -> Result<String, String> {
2019
     // Parse the command
2120
     let statement = rush_parser::parse_line(cmd)
2221
         .map_err(|e| format!("Parse error: {}", e))?;
2322
 
24
-    // Use a pipe to capture stdout
23
+    // Use fork + pipe to capture stdout (like bash does)
2524
     #[cfg(unix)]
2625
     {
27
-        execute_with_capture_unix(statement, context)
26
+        execute_with_fork_capture(statement, context)
2827
     }
2928
 
3029
     #[cfg(not(unix))]
@@ -36,82 +35,111 @@ pub fn execute_for_substitution(cmd: &str, context: &mut Context) -> Result<Stri
3635
 }
3736
 
3837
 #[cfg(unix)]
39
-fn execute_with_capture_unix(
38
+fn execute_with_fork_capture(
4039
     statement: rush_parser::Statement,
4140
     context: &mut Context,
4241
 ) -> Result<String, String> {
4342
     use nix::libc;
44
-    use nix::unistd::{pipe, dup, dup2};
43
+    use nix::unistd::{pipe, dup2, fork, ForkResult};
44
+    use nix::sys::wait::{waitpid, WaitStatus};
4545
     use std::os::unix::io::{RawFd, FromRawFd, IntoRawFd};
46
-    use std::io::Read;
46
+    use std::io::{Read, Write};
4747
 
4848
     const STDOUT_FD: RawFd = 1;
4949
 
5050
     // Create a pipe for capturing output
5151
     let (read_fd, write_fd) = pipe().map_err(|e| format!("Failed to create pipe: {}", e))?;
52
-
53
-    // Get raw fds and take ownership to prevent automatic close
5452
     let read_raw_fd = read_fd.into_raw_fd();
5553
     let write_raw_fd = write_fd.into_raw_fd();
5654
 
57
-    // Save the original stdout
58
-    let saved_stdout = dup(STDOUT_FD).map_err(|e| format!("Failed to dup stdout: {}", e))?;
59
-    let saved_stdout_raw = saved_stdout.into_raw_fd();
55
+    // Fork to execute the command in a subprocess
56
+    match unsafe { fork() } {
57
+        Ok(ForkResult::Child) => {
58
+            // Child process: redirect stdout to pipe and execute
6059
 
61
-    // Redirect stdout to the write end of the pipe
62
-    dup2(write_raw_fd, STDOUT_FD).map_err(|e| format!("Failed to redirect stdout: {}", e))?;
60
+            // Close the read end of the pipe (child only writes)
61
+            unsafe { libc::close(read_raw_fd); }
6362
 
64
-    // Close the write_fd as we've duplicated it to stdout
65
-    // (write_fd is now owned by stdout, so we close the original)
66
-    unsafe { libc::close(write_raw_fd); }
63
+            // Redirect stdout to the write end of the pipe
64
+            if let Err(_) = dup2(write_raw_fd, STDOUT_FD) {
65
+                std::process::exit(1);
66
+            }
6767
 
68
-    // Execute the command
69
-    let exec_result = match statement {
70
-        rush_parser::Statement::Complete(cmd) => {
71
-            crate::control_flow::execute_complete_command(&cmd, context)
72
-        }
73
-        rush_parser::Statement::Script(commands) => {
74
-            let mut last_result = crate::command::success_result();
75
-            for cmd in &commands {
76
-                match crate::control_flow::execute_complete_command(cmd, context) {
77
-                    Ok(result) => last_result = result,
78
-                    Err(e) => {
79
-                        // Restore stdout before returning error
80
-                        let _ = dup2(saved_stdout_raw, STDOUT_FD);
81
-                        unsafe {
82
-                            libc::close(saved_stdout_raw);
83
-                            libc::close(read_raw_fd);
68
+            // Close the original write_fd (now that stdout points to it)
69
+            unsafe { libc::close(write_raw_fd); }
70
+
71
+            // Execute the command
72
+            let exit_code = match statement {
73
+                rush_parser::Statement::Complete(cmd) => {
74
+                    match crate::control_flow::execute_complete_command(&cmd, context) {
75
+                        Ok(result) => result.exit_code(),
76
+                        Err(_) => 1,
77
+                    }
78
+                }
79
+                rush_parser::Statement::Script(commands) => {
80
+                    let mut last_code = 0;
81
+                    for cmd in &commands {
82
+                        match crate::control_flow::execute_complete_command(cmd, context) {
83
+                            Ok(result) => last_code = result.exit_code(),
84
+                            Err(_) => {
85
+                                last_code = 1;
86
+                                break;
87
+                            }
8488
                         }
85
-                        return Err(format!("Execution error: {}", e));
8689
                     }
90
+                    last_code
8791
                 }
88
-            }
89
-            Ok(last_result)
92
+                rush_parser::Statement::Empty => 0,
93
+            };
94
+
95
+            // Ensure all output is flushed before exiting
96
+            let _ = std::io::stdout().flush();
97
+
98
+            // Exit the child process
99
+            std::process::exit(exit_code);
90100
         }
91
-        rush_parser::Statement::Empty => Ok(crate::command::success_result()),
92
-    };
101
+        Ok(ForkResult::Parent { child }) => {
102
+            // Parent process: close write end and read from pipe
93103
 
94
-    // Flush stdout to ensure all data is written to the pipe
95
-    let _ = std::io::stdout().flush();
104
+            // Close the write end (parent only reads)
105
+            unsafe { libc::close(write_raw_fd); }
96106
 
97
-    // Restore the original stdout
98
-    dup2(saved_stdout_raw, STDOUT_FD).map_err(|e| format!("Failed to restore stdout: {}", e))?;
99
-    unsafe { libc::close(saved_stdout_raw); }
107
+            // Read all output from the pipe
108
+            let mut output = String::new();
109
+            let mut reader = unsafe { std::fs::File::from_raw_fd(read_raw_fd) };
110
+            if let Err(e) = reader.read_to_string(&mut output) {
111
+                return Err(format!("Failed to read output: {}", e));
112
+            }
100113
 
101
-    // Check execution result
102
-    exec_result.map_err(|e| format!("Execution error: {}", e))?;
114
+            // Wait for child to complete
115
+            match waitpid(child, None) {
116
+                Ok(WaitStatus::Exited(_, code)) => {
117
+                    context.set_exit_status(code);
118
+                }
119
+                Ok(_) => {
120
+                    context.set_exit_status(0);
121
+                }
122
+                Err(e) => {
123
+                    return Err(format!("Failed to wait for child: {}", e));
124
+                }
125
+            }
103126
 
104
-    // Read the captured output from the pipe
105
-    let mut output = String::new();
106
-    let mut reader = unsafe { std::fs::File::from_raw_fd(read_raw_fd) };
107
-    reader.read_to_string(&mut output).map_err(|e| format!("Failed to read output: {}", e))?;
127
+            // Bash behavior: trim trailing newlines from command substitution
128
+            while output.ends_with('\n') {
129
+                output.pop();
130
+            }
108131
 
109
-    // Bash behavior: trim trailing newlines from command substitution
110
-    while output.ends_with('\n') {
111
-        output.pop();
132
+            Ok(output)
133
+        }
134
+        Err(e) => {
135
+            // Fork failed, clean up
136
+            unsafe {
137
+                libc::close(read_raw_fd);
138
+                libc::close(write_raw_fd);
139
+            }
140
+            Err(format!("Failed to fork: {}", e))
141
+        }
112142
     }
113
-
114
-    Ok(output)
115143
 }
116144
 
117145
 #[cfg(test)]
crates/rush-executor/src/lib.rsmodified
@@ -12,7 +12,7 @@ pub mod terminal;
1212
 pub mod test_builtin;
1313
 
1414
 pub use background::{execute_pipeline_background, execute_simple_background};
15
-pub use command::{execute_command, ExecutionError, ExecutionResult};
15
+pub use command::{execute_command, execute_statement, ExecutionError, ExecutionResult};
1616
 #[cfg(unix)]
1717
 pub use command::JobControlInfo;
1818
 pub use command_subst_exec::execute_for_substitution;
crates/rush-executor/src/pipeline.rsmodified
@@ -68,9 +68,13 @@ pub fn execute_pipeline(
6868
     }
6969
 
7070
     // Build and spawn all commands in the pipeline
71
+    // We track raw file descriptors for proper subshell stdin handling
7172
     let mut children = Vec::new();
72
-    let mut subshell_pids = Vec::new(); // Track subshell PIDs separately
73
-    let mut prev_stdout = None;
73
+    let mut subshell_pids = Vec::new();
74
+    #[cfg(unix)]
75
+    let mut prev_stdout_fd: Option<i32> = None;
76
+    #[cfg(not(unix))]
77
+    let mut prev_stdout: Option<Stdio> = None;
7478
 
7579
     for (i, element) in pipeline.commands.iter().enumerate() {
7680
         let is_first = i == 0;
@@ -98,10 +102,22 @@ pub fn execute_pipeline(
98102
                 cmd.args(args);
99103
 
100104
                 // Set up stdin
101
-                if is_first {
102
-                    cmd.stdin(Stdio::inherit());
103
-                } else if let Some(prev_out) = prev_stdout.take() {
104
-                    cmd.stdin(prev_out);
105
+                #[cfg(unix)]
106
+                {
107
+                    use std::os::unix::io::FromRawFd;
108
+                    if is_first {
109
+                        cmd.stdin(Stdio::inherit());
110
+                    } else if let Some(fd) = prev_stdout_fd.take() {
111
+                        cmd.stdin(Stdio::from(unsafe { std::fs::File::from_raw_fd(fd) }));
112
+                    }
113
+                }
114
+                #[cfg(not(unix))]
115
+                {
116
+                    if is_first {
117
+                        cmd.stdin(Stdio::inherit());
118
+                    } else if let Some(prev_out) = prev_stdout.take() {
119
+                        cmd.stdin(prev_out);
120
+                    }
105121
                 }
106122
 
107123
                 // Set up stdout
@@ -116,7 +132,15 @@ pub fn execute_pipeline(
116132
 
117133
                 let mut child = cmd.spawn()?;
118134
                 if !is_last {
119
-                    prev_stdout = child.stdout.take().map(Stdio::from);
135
+                    #[cfg(unix)]
136
+                    {
137
+                        use std::os::unix::io::IntoRawFd;
138
+                        prev_stdout_fd = child.stdout.take().map(|f| f.into_raw_fd());
139
+                    }
140
+                    #[cfg(not(unix))]
141
+                    {
142
+                        prev_stdout = child.stdout.take().map(Stdio::from);
143
+                    }
120144
                 }
121145
                 children.push(child);
122146
             }
@@ -125,7 +149,10 @@ pub fn execute_pipeline(
125149
                 #[cfg(unix)]
126150
                 {
127151
                     use nix::unistd::{fork, ForkResult, pipe as nix_pipe, dup2};
128
-                    use std::os::unix::io::{IntoRawFd, FromRawFd};
152
+                    use std::os::unix::io::IntoRawFd;
153
+
154
+                    // Take stdin fd from previous command
155
+                    let stdin_fd = prev_stdout_fd.take();
129156
 
130157
                     // Create pipe for stdout if not last
131158
                     let pipe_fds = if !is_last {
@@ -142,12 +169,10 @@ pub fn execute_pipeline(
142169
                             // Child process: execute subshell with redirected I/O
143170
                             use nix::libc;
144171
 
145
-                            // Set up stdin from previous command if we have one
146
-                            if let Some(_prev) = prev_stdout {
147
-                                // _prev is Stdio, we need to extract the file descriptor
148
-                                // This is tricky - Stdio doesn't expose the raw FD easily
149
-                                // We'll skip stdin redirection for now in subshells
150
-                                // TODO: Properly handle stdin from previous pipeline element
172
+                            // Set up stdin from previous command
173
+                            if let Some(fd) = stdin_fd {
174
+                                let _ = dup2(fd, 0); // Redirect stdin
175
+                                unsafe { libc::close(fd); }
151176
                             }
152177
 
153178
                             // Set up stdout to pipe if not last
@@ -162,11 +187,17 @@ pub fn execute_pipeline(
162187
                             std::process::exit(exit_code);
163188
                         }
164189
                         Ok(ForkResult::Parent { child }) => {
165
-                            // Parent process: save pipe and track child PID
190
+                            // Parent process: close stdin fd and save stdout pipe
166191
                             use nix::libc;
192
+
193
+                            // Close the stdin fd in parent (child has its own copy)
194
+                            if let Some(fd) = stdin_fd {
195
+                                unsafe { libc::close(fd); }
196
+                            }
197
+
167198
                             if let Some((read_fd, write_fd)) = pipe_fds {
168199
                                 unsafe { libc::close(write_fd); }
169
-                                prev_stdout = Some(Stdio::from(unsafe { std::fs::File::from_raw_fd(read_fd) }));
200
+                                prev_stdout_fd = Some(read_fd);
170201
                             }
171202
 
172203
                             // Track the subshell PID for later waiting
crates/rush-expand/src/context.rsmodified
@@ -97,6 +97,8 @@ pub struct Context {
9797
     local_scopes: Vec<HashMap<String, String>>,
9898
     /// Exit status of last command
9999
     pub last_exit_status: i32,
100
+    /// Flag indicating the shell should exit (set by `exit` builtin)
101
+    pub exit_requested: Option<i32>,
100102
     /// Shell functions (name -> body)
101103
     pub functions: HashMap<String, rush_parser::ast::FunctionDef>,
102104
     /// Arrays (name -> indexed or associative)
@@ -126,6 +128,8 @@ pub struct Context {
126128
     /// Internal command executor (for command substitution)
127129
     /// If set, command substitution will use this instead of sh -c
128130
     pub command_executor: CommandExecutorWrapper,
131
+    /// Directory stack for pushd/popd
132
+    pub dir_stack: Vec<String>,
129133
 }
130134
 
131135
 impl Context {
@@ -136,6 +140,7 @@ impl Context {
136140
             exported: HashMap::new(),
137141
             local_scopes: Vec::new(),
138142
             last_exit_status: 0,
143
+            exit_requested: None,
139144
             functions: HashMap::new(),
140145
             arrays: HashMap::new(),
141146
             associative_array_names: HashSet::new(),
@@ -151,6 +156,7 @@ impl Context {
151156
             #[cfg(unix)]
152157
             coproc: None,
153158
             command_executor: CommandExecutorWrapper(None),
159
+            dir_stack: Vec::new(),
154160
         };
155161
 
156162
         // Initialize with environment variables
@@ -169,6 +175,7 @@ impl Context {
169175
             exported: HashMap::new(),
170176
             local_scopes: Vec::new(),
171177
             last_exit_status: 0,
178
+            exit_requested: None,
172179
             functions: HashMap::new(),
173180
             arrays: HashMap::new(),
174181
             associative_array_names: HashSet::new(),
@@ -184,6 +191,7 @@ impl Context {
184191
             #[cfg(unix)]
185192
             coproc: None,
186193
             command_executor: CommandExecutorWrapper(None),
194
+            dir_stack: Vec::new(),
187195
         }
188196
     }
189197
 
crates/rush-interactive/Cargo.tomlmodified
@@ -11,3 +11,9 @@ rush-parser = { path = "../rush-parser" }
1111
 reedline = { workspace = true }
1212
 nu-ansi-term = { workspace = true }
1313
 crossterm = { workspace = true }
14
+chrono = { workspace = true }
15
+hostname = { workspace = true }
16
+dirs = { workspace = true }
17
+
18
+[target.'cfg(unix)'.dependencies]
19
+nix = { workspace = true }
crates/rush-interactive/src/completer.rsmodified
@@ -1,3 +1,4 @@
1
+use crate::completion_spec::{with_registry, CompletionSource};
12
 use reedline::{Completer, Span, Suggestion};
23
 use std::env;
34
 use std::fs;
@@ -7,6 +8,7 @@ use std::path::PathBuf;
78
 ///
89
 /// Provides context-aware completions:
910
 /// - Command names from PATH (when first word)
11
+/// - Command-specific completions (from `complete` builtin)
1012
 /// - File/directory names (for arguments)
1113
 /// - Variable names (when typing $VAR)
1214
 pub struct RushCompleter;
@@ -65,7 +67,8 @@ impl RushCompleter {
6567
             "read", "shift", "wait", "kill", "times", "umask", "hash",
6668
             "getopts", "exec", "command", "jobs", "fg", "bg",
6769
             "coproc", "disown", "printf", "mapfile", "readarray",
68
-            "break", "continue", "return", "source", ".",
70
+            "break", "continue", "return", "source", ".", "complete",
71
+            "pushd", "popd", "dirs",
6972
         ];
7073
         commands.extend(builtins.iter().map(|s| s.to_string()));
7174
 
@@ -166,6 +169,101 @@ impl RushCompleter {
166169
             .unwrap_or(0);
167170
         (start, &line[start..pos])
168171
     }
172
+
173
+    /// Parse the command line to get command name and argument position
174
+    fn parse_command_line(line: &str, pos: usize) -> Option<(String, Vec<String>, usize)> {
175
+        let before_cursor = &line[..pos];
176
+        let words: Vec<&str> = before_cursor.split_whitespace().collect();
177
+
178
+        if words.is_empty() {
179
+            return None;
180
+        }
181
+
182
+        let command = words[0].to_string();
183
+        let args: Vec<String> = words[1..].iter().map(|s| s.to_string()).collect();
184
+        let arg_index = if args.is_empty() { 0 } else { args.len() - 1 };
185
+
186
+        Some((command, args, arg_index))
187
+    }
188
+
189
+    /// Get command-specific completions from the registry
190
+    fn get_command_completions(command: &str, partial: &str) -> Vec<(String, Option<String>)> {
191
+        let mut completions = Vec::new();
192
+
193
+        with_registry(|registry| {
194
+            if let Some(specs) = registry.get(command) {
195
+                for spec in specs {
196
+                    // TODO: Check condition if specified
197
+                    // For now, we'll skip condition checking
198
+
199
+                    match &spec.source {
200
+                        CompletionSource::Static(items) => {
201
+                            for item in items {
202
+                                if item.starts_with(partial) {
203
+                                    completions.push((
204
+                                        item.clone(),
205
+                                        spec.description.clone(),
206
+                                    ));
207
+                                }
208
+                            }
209
+                        }
210
+                        CompletionSource::Dynamic(cmd) => {
211
+                            // Execute the command and use output lines as completions
212
+                            if let Ok(output) = std::process::Command::new("sh")
213
+                                .arg("-c")
214
+                                .arg(cmd)
215
+                                .output()
216
+                            {
217
+                                let stdout = String::from_utf8_lossy(&output.stdout);
218
+                                for line in stdout.lines() {
219
+                                    let trimmed = line.trim();
220
+                                    if !trimmed.is_empty() && trimmed.starts_with(partial) {
221
+                                        completions.push((
222
+                                            trimmed.to_string(),
223
+                                            spec.description.clone(),
224
+                                        ));
225
+                                    }
226
+                                }
227
+                            }
228
+                        }
229
+                        CompletionSource::ShortOption(c) => {
230
+                            let opt = format!("-{}", c);
231
+                            if opt.starts_with(partial) {
232
+                                completions.push((opt, spec.description.clone()));
233
+                            }
234
+                        }
235
+                        CompletionSource::LongOption(s) => {
236
+                            let opt = format!("--{}", s);
237
+                            if opt.starts_with(partial) {
238
+                                completions.push((opt, spec.description.clone()));
239
+                            }
240
+                        }
241
+                        CompletionSource::Option { short, long } => {
242
+                            if let Some(c) = short {
243
+                                let opt = format!("-{}", c);
244
+                                if opt.starts_with(partial) {
245
+                                    completions.push((opt, spec.description.clone()));
246
+                                }
247
+                            }
248
+                            if let Some(l) = long {
249
+                                let opt = format!("--{}", l);
250
+                                if opt.starts_with(partial) {
251
+                                    completions.push((opt, spec.description.clone()));
252
+                                }
253
+                            }
254
+                        }
255
+                    }
256
+                }
257
+            }
258
+        });
259
+
260
+        completions
261
+    }
262
+
263
+    /// Check if a command has file completion disabled
264
+    fn has_no_files(command: &str) -> bool {
265
+        with_registry(|registry| registry.has_no_files(command))
266
+    }
169267
 }
170268
 
171269
 impl Default for RushCompleter {
@@ -199,20 +297,54 @@ impl Completer for RushCompleter {
199297
                 }
200298
             }
201299
         } else {
202
-            // Complete file/directory names
203
-            // When partial is empty (e.g., "ls "), show all non-hidden files
204
-            for file in Self::get_file_completions(partial) {
205
-                suggestions.push(Suggestion {
206
-                    value: file.clone(),
207
-                    description: None,
208
-                    style: None,
209
-                    extra: None,
210
-                    span,
211
-                    append_whitespace: !file.ends_with('/'),
212
-                });
300
+            // Parse the command line to get the command being completed
301
+            if let Some((command, _args, _arg_idx)) = Self::parse_command_line(line, pos) {
302
+                // Get command-specific completions
303
+                let cmd_completions = Self::get_command_completions(&command, partial);
304
+
305
+                for (value, desc) in cmd_completions {
306
+                    suggestions.push(Suggestion {
307
+                        value,
308
+                        description: desc,
309
+                        style: None,
310
+                        extra: None,
311
+                        span,
312
+                        append_whitespace: true,
313
+                    });
314
+                }
315
+
316
+                // Add file completions unless disabled for this command
317
+                if !Self::has_no_files(&command) {
318
+                    for file in Self::get_file_completions(partial) {
319
+                        suggestions.push(Suggestion {
320
+                            value: file.clone(),
321
+                            description: None,
322
+                            style: None,
323
+                            extra: None,
324
+                            span,
325
+                            append_whitespace: !file.ends_with('/'),
326
+                        });
327
+                    }
328
+                }
329
+            } else {
330
+                // Fallback to file completions if we can't parse the command
331
+                for file in Self::get_file_completions(partial) {
332
+                    suggestions.push(Suggestion {
333
+                        value: file.clone(),
334
+                        description: None,
335
+                        style: None,
336
+                        extra: None,
337
+                        span,
338
+                        append_whitespace: !file.ends_with('/'),
339
+                    });
340
+                }
213341
             }
214342
         }
215343
 
344
+        // Remove duplicates (command completions might overlap with files)
345
+        suggestions.sort_by(|a, b| a.value.cmp(&b.value));
346
+        suggestions.dedup_by(|a, b| a.value == b.value);
347
+
216348
         suggestions
217349
     }
218350
 }
crates/rush-interactive/src/completion_spec.rsadded
@@ -0,0 +1,353 @@
1
+//! Command-specific completion specifications
2
+//!
3
+//! This module provides a fish-style completion system where users can define
4
+//! custom completions for specific commands using the `complete` builtin.
5
+//!
6
+//! Example usage:
7
+//! ```bash
8
+//! # Add subcommand completions for git
9
+//! complete -c git -a "add commit push pull fetch checkout branch"
10
+//!
11
+//! # Add dynamic branch completions for git checkout
12
+//! complete -c git -n "__rush_seen_subcommand checkout" -a "(git branch --format='%(refname:short)')"
13
+//!
14
+//! # Add option completions
15
+//! complete -c ls -s l -d "Long format"
16
+//! complete -c ls -s a -l all -d "Show hidden files"
17
+//!
18
+//! # Disable file completion for a command
19
+//! complete -c git -f
20
+//! ```
21
+
22
+use std::collections::HashMap;
23
+use std::sync::RwLock;
24
+
25
+/// Global registry of completion specifications
26
+static COMPLETION_REGISTRY: RwLock<Option<CompletionRegistry>> = RwLock::new(None);
27
+
28
+/// A single completion specification
29
+#[derive(Debug, Clone)]
30
+pub struct CompletionSpec {
31
+    /// The command this completion applies to
32
+    pub command: String,
33
+
34
+    /// Condition that must be true for this completion to apply
35
+    /// This is a shell expression that will be evaluated
36
+    pub condition: Option<String>,
37
+
38
+    /// The completions to provide
39
+    pub source: CompletionSource,
40
+
41
+    /// Description for these completions
42
+    pub description: Option<String>,
43
+
44
+    /// If true, don't also complete files
45
+    pub no_files: bool,
46
+}
47
+
48
+/// Source of completion values
49
+#[derive(Debug, Clone)]
50
+pub enum CompletionSource {
51
+    /// Static list of completion strings
52
+    Static(Vec<String>),
53
+
54
+    /// Dynamic command to run (output lines become completions)
55
+    Dynamic(String),
56
+
57
+    /// Short option (e.g., -v)
58
+    ShortOption(char),
59
+
60
+    /// Long option (e.g., --verbose)
61
+    LongOption(String),
62
+
63
+    /// Short and long option together (e.g., -v/--verbose)
64
+    Option {
65
+        short: Option<char>,
66
+        long: Option<String>,
67
+    },
68
+}
69
+
70
+/// Registry holding all completion specifications
71
+#[derive(Debug, Default)]
72
+pub struct CompletionRegistry {
73
+    /// Completions indexed by command name
74
+    specs: HashMap<String, Vec<CompletionSpec>>,
75
+
76
+    /// Commands that should not have file completion
77
+    no_file_commands: std::collections::HashSet<String>,
78
+}
79
+
80
+impl CompletionRegistry {
81
+    pub fn new() -> Self {
82
+        Self::default()
83
+    }
84
+
85
+    /// Add a completion specification
86
+    pub fn add(&mut self, spec: CompletionSpec) {
87
+        if spec.no_files {
88
+            self.no_file_commands.insert(spec.command.clone());
89
+        }
90
+        self.specs
91
+            .entry(spec.command.clone())
92
+            .or_default()
93
+            .push(spec);
94
+    }
95
+
96
+    /// Remove all completions for a command
97
+    pub fn remove(&mut self, command: &str) {
98
+        self.specs.remove(command);
99
+        self.no_file_commands.remove(command);
100
+    }
101
+
102
+    /// Get completions for a command
103
+    pub fn get(&self, command: &str) -> Option<&Vec<CompletionSpec>> {
104
+        self.specs.get(command)
105
+    }
106
+
107
+    /// Check if a command has file completion disabled
108
+    pub fn has_no_files(&self, command: &str) -> bool {
109
+        self.no_file_commands.contains(command)
110
+    }
111
+
112
+    /// Get all registered commands
113
+    pub fn commands(&self) -> Vec<&String> {
114
+        self.specs.keys().collect()
115
+    }
116
+
117
+    /// Get all specs (for listing)
118
+    pub fn all_specs(&self) -> &HashMap<String, Vec<CompletionSpec>> {
119
+        &self.specs
120
+    }
121
+}
122
+
123
+/// Initialize the global completion registry
124
+pub fn init_registry() {
125
+    let mut registry = COMPLETION_REGISTRY.write().unwrap();
126
+    if registry.is_none() {
127
+        let mut reg = CompletionRegistry::new();
128
+        // Add default completions
129
+        add_default_completions(&mut reg);
130
+        *registry = Some(reg);
131
+    }
132
+}
133
+
134
+/// Get a reference to the global registry for reading
135
+pub fn with_registry<F, R>(f: F) -> R
136
+where
137
+    F: FnOnce(&CompletionRegistry) -> R,
138
+{
139
+    init_registry();
140
+    let registry = COMPLETION_REGISTRY.read().unwrap();
141
+    f(registry.as_ref().unwrap())
142
+}
143
+
144
+/// Get a mutable reference to the global registry
145
+pub fn with_registry_mut<F, R>(f: F) -> R
146
+where
147
+    F: FnOnce(&mut CompletionRegistry) -> R,
148
+{
149
+    init_registry();
150
+    let mut registry = COMPLETION_REGISTRY.write().unwrap();
151
+    f(registry.as_mut().unwrap())
152
+}
153
+
154
+/// Add a completion spec to the global registry
155
+pub fn add_completion(spec: CompletionSpec) {
156
+    with_registry_mut(|reg| reg.add(spec));
157
+}
158
+
159
+/// Remove completions for a command from the global registry
160
+pub fn remove_completions(command: &str) {
161
+    with_registry_mut(|reg| reg.remove(command));
162
+}
163
+
164
+/// Add default completions for common commands
165
+fn add_default_completions(registry: &mut CompletionRegistry) {
166
+    // Git completions
167
+    add_git_completions(registry);
168
+
169
+    // Cargo completions
170
+    add_cargo_completions(registry);
171
+}
172
+
173
+fn add_git_completions(registry: &mut CompletionRegistry) {
174
+    // Git subcommands
175
+    let git_subcommands = vec![
176
+        "add", "bisect", "branch", "checkout", "clone", "commit", "diff",
177
+        "fetch", "grep", "init", "log", "merge", "mv", "pull", "push",
178
+        "rebase", "reset", "restore", "rm", "show", "stash", "status",
179
+        "switch", "tag", "worktree",
180
+    ];
181
+
182
+    registry.add(CompletionSpec {
183
+        command: "git".to_string(),
184
+        condition: None,
185
+        source: CompletionSource::Static(git_subcommands.into_iter().map(String::from).collect()),
186
+        description: Some("Git subcommand".to_string()),
187
+        no_files: false,
188
+    });
189
+
190
+    // Git branch completion for checkout/switch
191
+    registry.add(CompletionSpec {
192
+        command: "git".to_string(),
193
+        condition: Some("__rush_git_needs_branch".to_string()),
194
+        source: CompletionSource::Dynamic("git branch --format='%(refname:short)' 2>/dev/null".to_string()),
195
+        description: Some("Branch name".to_string()),
196
+        no_files: true,
197
+    });
198
+
199
+    // Common git options
200
+    registry.add(CompletionSpec {
201
+        command: "git".to_string(),
202
+        condition: None,
203
+        source: CompletionSource::Option {
204
+            short: Some('v'),
205
+            long: Some("verbose".to_string()),
206
+        },
207
+        description: Some("Be verbose".to_string()),
208
+        no_files: false,
209
+    });
210
+
211
+    registry.add(CompletionSpec {
212
+        command: "git".to_string(),
213
+        condition: None,
214
+        source: CompletionSource::Option {
215
+            short: Some('h'),
216
+            long: Some("help".to_string()),
217
+        },
218
+        description: Some("Show help".to_string()),
219
+        no_files: false,
220
+    });
221
+}
222
+
223
+fn add_cargo_completions(registry: &mut CompletionRegistry) {
224
+    // Cargo subcommands
225
+    let cargo_subcommands = vec![
226
+        "build", "check", "clean", "doc", "new", "init", "add", "remove",
227
+        "run", "test", "bench", "update", "search", "publish", "install",
228
+        "uninstall", "fmt", "clippy", "fix", "tree", "vendor",
229
+    ];
230
+
231
+    registry.add(CompletionSpec {
232
+        command: "cargo".to_string(),
233
+        condition: None,
234
+        source: CompletionSource::Static(cargo_subcommands.into_iter().map(String::from).collect()),
235
+        description: Some("Cargo subcommand".to_string()),
236
+        no_files: false,
237
+    });
238
+
239
+    // Common cargo options
240
+    registry.add(CompletionSpec {
241
+        command: "cargo".to_string(),
242
+        condition: None,
243
+        source: CompletionSource::Option {
244
+            short: None,
245
+            long: Some("release".to_string()),
246
+        },
247
+        description: Some("Build in release mode".to_string()),
248
+        no_files: false,
249
+    });
250
+
251
+    registry.add(CompletionSpec {
252
+        command: "cargo".to_string(),
253
+        condition: None,
254
+        source: CompletionSource::Option {
255
+            short: None,
256
+            long: Some("all-features".to_string()),
257
+        },
258
+        description: Some("Enable all features".to_string()),
259
+        no_files: false,
260
+    });
261
+
262
+    registry.add(CompletionSpec {
263
+        command: "cargo".to_string(),
264
+        condition: None,
265
+        source: CompletionSource::Option {
266
+            short: Some('p'),
267
+            long: Some("package".to_string()),
268
+        },
269
+        description: Some("Package to build".to_string()),
270
+        no_files: false,
271
+    });
272
+
273
+    registry.add(CompletionSpec {
274
+        command: "cargo".to_string(),
275
+        condition: None,
276
+        source: CompletionSource::Option {
277
+            short: None,
278
+            long: Some("bin".to_string()),
279
+        },
280
+        description: Some("Binary to run".to_string()),
281
+        no_files: false,
282
+    });
283
+}
284
+
285
+/// Helper struct for building completion specs fluently
286
+pub struct CompletionBuilder {
287
+    spec: CompletionSpec,
288
+}
289
+
290
+impl CompletionBuilder {
291
+    pub fn new(command: &str) -> Self {
292
+        Self {
293
+            spec: CompletionSpec {
294
+                command: command.to_string(),
295
+                condition: None,
296
+                source: CompletionSource::Static(vec![]),
297
+                description: None,
298
+                no_files: false,
299
+            },
300
+        }
301
+    }
302
+
303
+    pub fn condition(mut self, cond: &str) -> Self {
304
+        self.spec.condition = Some(cond.to_string());
305
+        self
306
+    }
307
+
308
+    pub fn completions(mut self, completions: Vec<String>) -> Self {
309
+        self.spec.source = CompletionSource::Static(completions);
310
+        self
311
+    }
312
+
313
+    pub fn dynamic(mut self, command: &str) -> Self {
314
+        self.spec.source = CompletionSource::Dynamic(command.to_string());
315
+        self
316
+    }
317
+
318
+    pub fn short_option(mut self, opt: char) -> Self {
319
+        self.spec.source = CompletionSource::ShortOption(opt);
320
+        self
321
+    }
322
+
323
+    pub fn long_option(mut self, opt: &str) -> Self {
324
+        self.spec.source = CompletionSource::LongOption(opt.to_string());
325
+        self
326
+    }
327
+
328
+    pub fn option(mut self, short: Option<char>, long: Option<&str>) -> Self {
329
+        self.spec.source = CompletionSource::Option {
330
+            short,
331
+            long: long.map(String::from),
332
+        };
333
+        self
334
+    }
335
+
336
+    pub fn description(mut self, desc: &str) -> Self {
337
+        self.spec.description = Some(desc.to_string());
338
+        self
339
+    }
340
+
341
+    pub fn no_files(mut self) -> Self {
342
+        self.spec.no_files = true;
343
+        self
344
+    }
345
+
346
+    pub fn build(self) -> CompletionSpec {
347
+        self.spec
348
+    }
349
+
350
+    pub fn register(self) {
351
+        add_completion(self.build());
352
+    }
353
+}
crates/rush-interactive/src/highlighter.rsmodified
@@ -49,7 +49,14 @@ impl RushHighlighter {
4949
     fn is_builtin(command: &str) -> bool {
5050
         matches!(
5151
             command,
52
-            "cd" | "pwd" | "exit" | "jobs" | "fg" | "bg" | "test" | "["
52
+            "cd" | "pwd" | "exit" | "true" | "false" | "test" | "[" | ":"
53
+                | "eval" | "alias" | "unalias" | "trap" | "set" | "shopt"
54
+                | "export" | "unset" | "readonly" | "declare" | "typeset" | "local"
55
+                | "read" | "shift" | "wait" | "kill" | "times" | "umask" | "hash"
56
+                | "getopts" | "exec" | "command" | "jobs" | "fg" | "bg"
57
+                | "coproc" | "disown" | "printf" | "mapfile" | "readarray"
58
+                | "break" | "continue" | "return" | "source" | "."
59
+                | "complete" | "pushd" | "popd" | "dirs"
5360
         )
5461
     }
5562
 
crates/rush-interactive/src/lib.rsmodified
@@ -1,11 +1,18 @@
11
 // Rush interactive - Fish-like interactive features
22
 
33
 pub mod completer;
4
+pub mod completion_spec;
45
 pub mod error_hints;
56
 pub mod highlighter;
67
 pub mod hinter;
8
+pub mod prompt;
79
 
810
 pub use completer::RushCompleter;
11
+pub use completion_spec::{
12
+    add_completion, remove_completions, with_registry, with_registry_mut,
13
+    CompletionBuilder, CompletionRegistry, CompletionSource, CompletionSpec,
14
+};
915
 pub use error_hints::ErrorHints;
1016
 pub use highlighter::RushHighlighter;
1117
 pub use hinter::RushHinter;
18
+pub use prompt::RushPrompt;
crates/rush-interactive/src/prompt.rsadded
@@ -0,0 +1,257 @@
1
+//! Custom shell prompt with PS1 support
2
+//!
3
+//! Implements bash-compatible prompt escape sequences:
4
+//! - `\u` - Username
5
+//! - `\h` - Hostname (short)
6
+//! - `\H` - Hostname (full)
7
+//! - `\w` - Working directory (~ for home)
8
+//! - `\W` - Basename of working directory
9
+//! - `\$` - `#` if root, `$` otherwise
10
+//! - `\n` - Newline
11
+//! - `\r` - Carriage return
12
+//! - `\t` - Current time 24-hour (HH:MM:SS)
13
+//! - `\T` - Current time 12-hour (HH:MM:SS)
14
+//! - `\@` - Current time 12-hour with AM/PM (HH:MM AM/PM)
15
+//! - `\A` - Current time 24-hour (HH:MM)
16
+//! - `\d` - Date (Day Mon Date)
17
+//! - `\D{format}` - Custom strftime format (e.g., `\D{%Y-%m-%d}`)
18
+//! - `\\` - Literal backslash
19
+
20
+use reedline::Prompt;
21
+use std::borrow::Cow;
22
+use std::env;
23
+
24
+/// Custom prompt that supports PS1-style escape sequences
25
+pub struct RushPrompt {
26
+    /// Cached username
27
+    username: String,
28
+    /// Cached hostname (short)
29
+    hostname_short: String,
30
+    /// Cached hostname (full)
31
+    hostname_full: String,
32
+    /// Cached home directory
33
+    home_dir: Option<String>,
34
+}
35
+
36
+impl RushPrompt {
37
+    pub fn new() -> Self {
38
+        let username = env::var("USER")
39
+            .or_else(|_| env::var("USERNAME"))
40
+            .unwrap_or_else(|_| "user".to_string());
41
+
42
+        let hostname_full = hostname::get()
43
+            .map(|h| h.to_string_lossy().to_string())
44
+            .unwrap_or_else(|_| "localhost".to_string());
45
+
46
+        let hostname_short = hostname_full
47
+            .split('.')
48
+            .next()
49
+            .unwrap_or("localhost")
50
+            .to_string();
51
+
52
+        let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().to_string());
53
+
54
+        Self {
55
+            username,
56
+            hostname_short,
57
+            hostname_full,
58
+            home_dir,
59
+        }
60
+    }
61
+
62
+    /// Expand PS1-style escape sequences
63
+    pub fn expand_ps1(&self, ps1: &str) -> String {
64
+        let mut result = String::with_capacity(ps1.len() * 2);
65
+        let mut chars = ps1.chars().peekable();
66
+
67
+        while let Some(ch) = chars.next() {
68
+            if ch == '\\' {
69
+                if let Some(&next) = chars.peek() {
70
+                    chars.next();
71
+                    match next {
72
+                        'u' => result.push_str(&self.username),
73
+                        'h' => result.push_str(&self.hostname_short),
74
+                        'H' => result.push_str(&self.hostname_full),
75
+                        'w' => result.push_str(&self.get_working_dir(false)),
76
+                        'W' => result.push_str(&self.get_working_dir(true)),
77
+                        '$' => {
78
+                            // # for root, $ otherwise
79
+                            if self.is_root() {
80
+                                result.push('#');
81
+                            } else {
82
+                                result.push('$');
83
+                            }
84
+                        }
85
+                        'n' => result.push('\n'),
86
+                        'r' => result.push('\r'),
87
+                        't' => result.push_str(&self.get_time_24()),      // HH:MM:SS (24-hour)
88
+                        'T' => result.push_str(&self.get_time_12()),      // HH:MM:SS (12-hour)
89
+                        '@' => result.push_str(&self.get_time_12_ampm()), // HH:MM AM/PM
90
+                        'A' => result.push_str(&self.get_time_24_short()), // HH:MM (24-hour)
91
+                        'd' => result.push_str(&self.get_date()),
92
+                        'D' => {
93
+                            // Custom date format: \D{format}
94
+                            if chars.peek() == Some(&'{') {
95
+                                chars.next(); // consume '{'
96
+                                let mut format = String::new();
97
+                                while let Some(&c) = chars.peek() {
98
+                                    chars.next();
99
+                                    if c == '}' {
100
+                                        break;
101
+                                    }
102
+                                    format.push(c);
103
+                                }
104
+                                result.push_str(&self.get_custom_datetime(&format));
105
+                            } else {
106
+                                // No format specified, use default
107
+                                result.push_str(&self.get_date());
108
+                            }
109
+                        }
110
+                        '\\' => result.push('\\'),
111
+                        '[' => {} // Start of non-printing sequence (ignored for now)
112
+                        ']' => {} // End of non-printing sequence (ignored for now)
113
+                        _ => {
114
+                            // Unknown escape, keep as-is
115
+                            result.push('\\');
116
+                            result.push(next);
117
+                        }
118
+                    }
119
+                } else {
120
+                    result.push('\\');
121
+                }
122
+            } else {
123
+                result.push(ch);
124
+            }
125
+        }
126
+
127
+        result
128
+    }
129
+
130
+    /// Get the current working directory, optionally just the basename
131
+    fn get_working_dir(&self, basename_only: bool) -> String {
132
+        let cwd = env::current_dir()
133
+            .map(|p| p.to_string_lossy().to_string())
134
+            .unwrap_or_else(|_| "?".to_string());
135
+
136
+        if basename_only {
137
+            std::path::Path::new(&cwd)
138
+                .file_name()
139
+                .map(|n| n.to_string_lossy().to_string())
140
+                .unwrap_or_else(|| cwd.clone())
141
+        } else {
142
+            // Replace home directory with ~
143
+            if let Some(home) = &self.home_dir {
144
+                if cwd == *home {
145
+                    "~".to_string()
146
+                } else if cwd.starts_with(home) {
147
+                    format!("~{}", &cwd[home.len()..])
148
+                } else {
149
+                    cwd
150
+                }
151
+            } else {
152
+                cwd
153
+            }
154
+        }
155
+    }
156
+
157
+    /// Check if the current user is root
158
+    fn is_root(&self) -> bool {
159
+        #[cfg(unix)]
160
+        {
161
+            nix::unistd::getuid().is_root()
162
+        }
163
+        #[cfg(not(unix))]
164
+        {
165
+            false
166
+        }
167
+    }
168
+
169
+    /// Get current time in HH:MM:SS 24-hour format (\t)
170
+    fn get_time_24(&self) -> String {
171
+        chrono::Local::now().format("%H:%M:%S").to_string()
172
+    }
173
+
174
+    /// Get current time in HH:MM:SS 12-hour format (\T)
175
+    fn get_time_12(&self) -> String {
176
+        chrono::Local::now().format("%I:%M:%S").to_string()
177
+    }
178
+
179
+    /// Get current time in HH:MM AM/PM format (\@)
180
+    fn get_time_12_ampm(&self) -> String {
181
+        chrono::Local::now().format("%I:%M %p").to_string()
182
+    }
183
+
184
+    /// Get current time in HH:MM 24-hour format (\A)
185
+    fn get_time_24_short(&self) -> String {
186
+        chrono::Local::now().format("%H:%M").to_string()
187
+    }
188
+
189
+    /// Get current date in "Day Mon Date" format (\d)
190
+    fn get_date(&self) -> String {
191
+        chrono::Local::now().format("%a %b %d").to_string()
192
+    }
193
+
194
+    /// Get custom datetime format (\D{format})
195
+    fn get_custom_datetime(&self, format: &str) -> String {
196
+        chrono::Local::now().format(format).to_string()
197
+    }
198
+}
199
+
200
+impl Default for RushPrompt {
201
+    fn default() -> Self {
202
+        Self::new()
203
+    }
204
+}
205
+
206
+impl Prompt for RushPrompt {
207
+    fn render_prompt_left(&self) -> Cow<'_, str> {
208
+        // Check for PS1 environment variable
209
+        if let Ok(ps1) = env::var("PS1") {
210
+            Cow::Owned(self.expand_ps1(&ps1))
211
+        } else {
212
+            // Default prompt: path〉 (fish-style)
213
+            Cow::Owned(format!("{}〉", self.get_working_dir(false)))
214
+        }
215
+    }
216
+
217
+    fn render_prompt_right(&self) -> Cow<'_, str> {
218
+        // Check for PS1_RIGHT environment variable (custom extension)
219
+        if let Ok(ps1_right) = env::var("PS1_RIGHT") {
220
+            Cow::Owned(self.expand_ps1(&ps1_right))
221
+        } else {
222
+            // Default right prompt: date and time
223
+            Cow::Owned(chrono::Local::now().format("%m/%d/%Y %I:%M:%S %p").to_string())
224
+        }
225
+    }
226
+
227
+    fn render_prompt_indicator(&self, _edit_mode: reedline::PromptEditMode) -> Cow<'_, str> {
228
+        Cow::Borrowed("")
229
+    }
230
+
231
+    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
232
+        Cow::Borrowed("> ")
233
+    }
234
+
235
+    fn render_prompt_history_search_indicator(
236
+        &self,
237
+        _history_search: reedline::PromptHistorySearch,
238
+    ) -> Cow<'_, str> {
239
+        Cow::Borrowed("(search) ")
240
+    }
241
+
242
+    fn get_prompt_color(&self) -> reedline::Color {
243
+        reedline::Color::Reset
244
+    }
245
+
246
+    fn get_prompt_multiline_color(&self) -> nu_ansi_term::Color {
247
+        nu_ansi_term::Color::LightGray
248
+    }
249
+
250
+    fn get_indicator_color(&self) -> reedline::Color {
251
+        reedline::Color::Reset
252
+    }
253
+
254
+    fn get_prompt_right_color(&self) -> reedline::Color {
255
+        reedline::Color::Reset
256
+    }
257
+}
crates/rush-job/src/job.rsmodified
@@ -241,6 +241,28 @@ impl JobList {
241241
             self.remove_job(id);
242242
         }
243243
     }
244
+
245
+    /// Send SIGHUP to all running jobs
246
+    /// Called when the shell is exiting to notify background jobs
247
+    pub fn send_hup_to_all(&self) {
248
+        use nix::sys::signal::{killpg, Signal};
249
+
250
+        for job in self.jobs.values() {
251
+            if job.is_running() || job.is_stopped() {
252
+                // Send SIGHUP to the process group
253
+                let _ = killpg(job.pgid, Signal::SIGHUP);
254
+                // Also send SIGCONT in case the job was stopped
255
+                if job.is_stopped() {
256
+                    let _ = killpg(job.pgid, Signal::SIGCONT);
257
+                }
258
+            }
259
+        }
260
+    }
261
+
262
+    /// Check if there are any running or stopped jobs
263
+    pub fn has_active_jobs(&self) -> bool {
264
+        self.jobs.values().any(|job| job.is_running() || job.is_stopped())
265
+    }
244266
 }
245267
 
246268
 #[cfg(test)]
crates/rush-job/src/lib.rsmodified
@@ -13,8 +13,11 @@ pub mod signals;
1313
 pub mod terminal;
1414
 
1515
 pub use job::{Job, JobId, JobList, JobState};
16
-pub use signals::{check_children, setup_job_control_signals};
17
-pub use terminal::{give_terminal_to, restore_shell_terminal, setup_shell_terminal};
16
+pub use signals::{check_children, check_sighup, check_sigwinch, setup_job_control_signals};
17
+pub use terminal::{
18
+    give_terminal_to, get_terminal_attrs, restore_shell_terminal, restore_terminal_attrs,
19
+    save_terminal_attrs, set_terminal_attrs, setup_shell_terminal,
20
+};
1821
 
1922
 use thiserror::Error;
2023
 
crates/rush-job/src/signals.rsmodified
@@ -1,6 +1,23 @@
11
 use nix::sys::signal::{signal, SigHandler, Signal};
22
 use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
33
 use nix::unistd::Pid;
4
+use std::sync::atomic::{AtomicBool, Ordering};
5
+
6
+/// Flag indicating SIGWINCH was received (terminal resized)
7
+pub static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
8
+
9
+/// Flag indicating SIGHUP was received (terminal closed)
10
+pub static SIGHUP_RECEIVED: AtomicBool = AtomicBool::new(false);
11
+
12
+/// SIGWINCH handler - sets flag for main loop to check
13
+extern "C" fn handle_sigwinch(_: i32) {
14
+    SIGWINCH_RECEIVED.store(true, Ordering::SeqCst);
15
+}
16
+
17
+/// SIGHUP handler - sets flag for main loop to check
18
+extern "C" fn handle_sighup(_: i32) {
19
+    SIGHUP_RECEIVED.store(true, Ordering::SeqCst);
20
+}
421
 
522
 /// Setup job control signal handlers
623
 ///
@@ -8,13 +25,25 @@ use nix::unistd::Pid;
825
 /// - SIGCHLD: Reap completed/stopped child processes
926
 /// - SIGINT: Interrupt (Ctrl-C) - handled by foreground job
1027
 /// - SIGTSTP: Suspend (Ctrl-Z) - handled by foreground job
28
+/// - SIGWINCH: Terminal resize - flagged for notification
29
+/// - SIGHUP: Terminal hangup - flagged for cleanup
30
+/// - SIGQUIT: Quit (Ctrl-\) - ignored by shell
1131
 ///
1232
 /// Note: The actual signal handling is done via polling in the main loop,
1333
 /// not via signal handlers (to avoid async-signal-safety issues).
1434
 pub fn setup_job_control_signals() -> Result<(), nix::Error> {
15
-    // Set SIGCHLD to default (we'll poll for child status changes)
1635
     unsafe {
36
+        // Set SIGCHLD to default (we'll poll for child status changes)
1737
         signal(Signal::SIGCHLD, SigHandler::SigDfl)?;
38
+
39
+        // SIGWINCH: Handle terminal resize
40
+        signal(Signal::SIGWINCH, SigHandler::Handler(handle_sigwinch))?;
41
+
42
+        // SIGHUP: Handle terminal hangup (e.g., closing terminal window)
43
+        signal(Signal::SIGHUP, SigHandler::Handler(handle_sighup))?;
44
+
45
+        // SIGQUIT (Ctrl-\): Ignore in shell, let foreground job handle it
46
+        signal(Signal::SIGQUIT, SigHandler::SigIgn)?;
1847
     }
1948
 
2049
     // SIGINT and SIGTSTP are handled by the foreground job
@@ -23,6 +52,16 @@ pub fn setup_job_control_signals() -> Result<(), nix::Error> {
2352
     Ok(())
2453
 }
2554
 
55
+/// Check if terminal was resized
56
+pub fn check_sigwinch() -> bool {
57
+    SIGWINCH_RECEIVED.swap(false, Ordering::SeqCst)
58
+}
59
+
60
+/// Check if SIGHUP was received
61
+pub fn check_sighup() -> bool {
62
+    SIGHUP_RECEIVED.swap(false, Ordering::SeqCst)
63
+}
64
+
2665
 /// Check for completed or stopped child processes
2766
 ///
2867
 /// This should be called periodically (e.g., before displaying a prompt)
crates/rush-job/src/terminal.rsmodified
@@ -1,5 +1,11 @@
1
+use nix::sys::termios::{tcgetattr, tcsetattr, SetArg, Termios};
12
 use nix::unistd::{tcgetpgrp, tcsetpgrp, Pid};
23
 use std::io;
4
+use std::os::fd::AsFd;
5
+use std::sync::Mutex;
6
+
7
+/// Saved terminal attributes for restoration
8
+static SAVED_TERMIOS: Mutex<Option<Termios>> = Mutex::new(None);
39
 
410
 /// Give terminal control to a process group
511
 ///
@@ -10,6 +16,39 @@ pub fn give_terminal_to(pgid: Pid) -> Result<(), nix::Error> {
1016
     tcsetpgrp(&stdin, pgid)
1117
 }
1218
 
19
+/// Save current terminal attributes
20
+/// Call this at shell startup to preserve the initial terminal state
21
+pub fn save_terminal_attrs() -> Result<(), nix::Error> {
22
+    let stdin = io::stdin();
23
+    let termios = tcgetattr(stdin.as_fd())?;
24
+    let mut saved = SAVED_TERMIOS.lock().unwrap();
25
+    *saved = Some(termios);
26
+    Ok(())
27
+}
28
+
29
+/// Restore saved terminal attributes
30
+/// Call this when the shell exits or after a child misbehaves
31
+pub fn restore_terminal_attrs() -> Result<(), nix::Error> {
32
+    let saved = SAVED_TERMIOS.lock().unwrap();
33
+    if let Some(ref termios) = *saved {
34
+        let stdin = io::stdin();
35
+        tcsetattr(stdin.as_fd(), SetArg::TCSADRAIN, termios)?;
36
+    }
37
+    Ok(())
38
+}
39
+
40
+/// Get current terminal attributes
41
+pub fn get_terminal_attrs() -> Result<Termios, nix::Error> {
42
+    let stdin = io::stdin();
43
+    tcgetattr(stdin.as_fd())
44
+}
45
+
46
+/// Set terminal attributes
47
+pub fn set_terminal_attrs(termios: &Termios) -> Result<(), nix::Error> {
48
+    let stdin = io::stdin();
49
+    tcsetattr(stdin.as_fd(), SetArg::TCSADRAIN, termios)
50
+}
51
+
1352
 /// Get the current foreground process group of the terminal
1453
 pub fn get_terminal_pgid() -> Result<Pid, nix::Error> {
1554
     let stdin = io::stdin();