diff --git a/Cargo.lock b/Cargo.lock index 88d6cb0..54ab05c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 2bffdbf..b4e44e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.11" +version = "0.2.12" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/src/nav.rs b/src/nav.rs index 6ec0b52..c462d23 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -51,9 +51,8 @@ pub fn dir_size(path: &Path) -> Option { Some(total) } -fn modified_str(path: &Path) -> String { - std::fs::metadata(path) - .and_then(|m| m.modified()) +fn modified_text(meta: &std::fs::Metadata) -> String { + meta.modified() .ok() .map(|t| { let dt: chrono::DateTime = t.into(); @@ -94,59 +93,86 @@ fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool { } } +/// One ls row: (sort key, cells). A single symlink_metadata call per entry +/// (the modification time comes from the same metadata). +fn row_for(p: &Path, color: bool) -> Option<(String, Vec)> { + let meta = std::fs::symlink_metadata(p).ok()?; + let file_type = meta.file_type(); + let name = p.file_name().and_then(|n| n.to_str())?.to_string(); + let (kind, style) = if file_type.is_symlink() { + ("symlink", if p.exists() { 3 } else { 4 }) + } else if file_type.is_dir() { + ("dir", 1) + } else if is_executable(p, &meta) { + ("file", 2) + } else { + ("file", 0) + }; + let (size_text, size_bytes) = if file_type.is_dir() { + match dir_size(p) { + Some(s) => (format_size(s), Some(s)), + None => ("—".to_string(), None), + } + } else { + (format_size(meta.len()), Some(meta.len())) + }; + let mut display = name.clone(); + if file_type.is_dir() { + display.push('/'); + } else if file_type.is_symlink() { + if let Ok(target) = std::fs::read_link(p) { + display.push_str(&format!(" -> {}", target.display())); + } + } + Some(( + name.to_lowercase(), + vec![ + Cell::str(paint(&display, color, style)), + Cell::str(kind.to_string()), + Cell::bytes(size_bytes.unwrap_or(0), size_text), + Cell::str(modified_text(&meta)), + ], + )) +} + /// Build the ls table (columns: name, type, size, modified). Directories /// come first, then alphabetical order. +/// +/// Rows are built in parallel: recursive directory sizes dominate the cost, +/// so a listing with many subdirectories stays fast (like Nushell, which +/// also parallelizes its listing). pub fn ls_table(path: &Path, color: bool) -> Result { let rd = std::fs::read_dir(path) .with_context(|| format!("cannot read {}", path.display()))?; - let mut rows: Vec<(String, Vec)> = Vec::new(); - for entry in rd.flatten() { - let p = entry.path(); - let meta = match std::fs::symlink_metadata(&p) { - Ok(m) => m, - Err(_) => continue, - }; - let file_type = meta.file_type(); - let name = p - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let (kind, style) = if file_type.is_symlink() { - ("symlink", if p.exists() { 3 } else { 4 }) - } else if file_type.is_dir() { - ("dir", 1) - } else if is_executable(&p, &meta) { - ("file", 2) - } else { - ("file", 0) - }; - let (size_text, size_bytes) = if file_type.is_dir() { - match dir_size(&p) { - Some(s) => (format_size(s), Some(s)), - None => ("—".to_string(), None), - } - } else { - (format_size(meta.len()), Some(meta.len())) - }; - let mut display = name.clone(); - if file_type.is_dir() { - display.push('/'); - } else if file_type.is_symlink() { - if let Ok(target) = std::fs::read_link(&p) { - display.push_str(&format!(" -> {}", target.display())); + let entries: Vec = rd.flatten().map(|e| e.path()).collect(); + + let threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .min(8); + let chunk_size = entries.len().div_ceil(threads).max(1); + let rows: Vec<(String, Vec)> = std::thread::scope(|scope| { + let mut handles = Vec::new(); + for chunk in entries.chunks(chunk_size) { + let chunk: Vec = chunk.to_vec(); + handles.push(scope.spawn(move || { + chunk + .into_iter() + .filter_map(|p| row_for(&p, color)) + .collect::>() + })); + } + let mut out = Vec::new(); + for handle in handles { + if let Ok(mut rows) = handle.join() { + out.append(&mut rows); } } - rows.push(( - name.to_lowercase(), - vec![ - Cell::str(paint(&display, color, style)), - Cell::str(kind.to_string()), - Cell::bytes(size_bytes.unwrap_or(0), size_text), - Cell::str(modified_str(&p)), - ], - )); - } + out + }); + // Directories first, then alphabetical. + let mut rows = rows; rows.sort_by(|a, b| { let a_dir = a.1[1].text == "dir"; let b_dir = b.1[1].text == "dir"; diff --git a/src/repl.rs b/src/repl.rs index 2c229b2..68b7eac 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -475,7 +475,9 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String { } /// One-line status shown before every prompt (Hermes-style status bar). -fn print_status(app: &App, session: &ShellSession, started: Instant) { +/// The second parameter is the duration of the previous command (None before +/// the first one). +fn print_status(app: &App, session: &ShellSession, started: Instant, last: Option) { let color = app.color(); let sep = dim(" │ ", color); let cwd = shorten_head(¤t_dir_string(), 26); @@ -493,9 +495,16 @@ fn print_status(app: &App, session: &ShellSession, started: Instant) { ), color, ); + let last_text = match last { + Some(d) if d.as_secs() >= 1 => format_elapsed(d), + Some(d) if d.as_millis() >= 1 => format!("{}ms", d.as_millis()), + Some(d) => format!("{}µs", d.as_micros()), + None => "—".to_string(), + }; + let last_part = dim(&format!("⚡ {last_text}"), color); let elapsed = dim(&format_elapsed(started.elapsed()), color); println!( - "{}am v{}{sep}{shell_part}{sep}{counts}{sep}⏲ {elapsed}", + "{}am v{}{sep}{shell_part}{sep}{counts}{sep}{last_part}{sep}⏲ {elapsed}", dim(" ⚙ ", color), env!("CARGO_PKG_VERSION"), ); @@ -694,8 +703,9 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { } let started = Instant::now(); let mut last: Option = None; + let mut last_duration: Option = None; loop { - print_status(app, session, started); + print_status(app, session, started, last_duration); match rl.readline("❯ ") { Ok(line) => { let trimmed = line.trim().to_string(); @@ -703,7 +713,10 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { continue; } let _ = rl.add_history_entry(trimmed.as_str()); - match handle_input(app, session, &mut last, &trimmed) { + let t = Instant::now(); + let result = handle_input(app, session, &mut last, &trimmed); + last_duration = Some(t.elapsed()); + match result { Ok(true) => break, Ok(false) => {} Err(e) => app.log.error(&format!("{e:#}")), @@ -725,8 +738,9 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { fn run_plain(app: &App, session: &mut ShellSession) -> Result { let started = Instant::now(); let mut last: Option = None; + let mut last_duration: Option = None; loop { - print_status(app, session, started); + print_status(app, session, started, last_duration); eprint!("❯ "); std::io::stderr().flush().ok(); let mut line = String::new(); @@ -737,7 +751,10 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result { if line.is_empty() { continue; } - match handle_input(app, session, &mut last, &line) { + let t = Instant::now(); + let result = handle_input(app, session, &mut last, &line); + last_duration = Some(t.elapsed()); + match result { Ok(true) => break, Ok(false) => {} Err(e) => app.log.error(&format!("{e:#}")),