diff --git a/src/tui/app.rs b/src/tui/app.rs index af444a37f..28a27554c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -210,9 +210,8 @@ impl App { refresh_needed = true; } - // Tick the dialog spinner if loading - if self.home.is_creation_pending() { - self.home.tick_dialog(); + // Tick dialog animations/timers (spinner, transient flashes) + if self.home.tick_dialog() { refresh_needed = true; } diff --git a/src/tui/dialogs/new_session/mod.rs b/src/tui/dialogs/new_session/mod.rs index 37750db22..a302e9836 100644 --- a/src/tui/dialogs/new_session/mod.rs +++ b/src/tui/dialogs/new_session/mod.rs @@ -1,11 +1,13 @@ //! New session dialog +mod path_input; mod render; #[cfg(test)] mod tests; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Instant; use tui_input::backend::crossterm::EventHandler; use tui_input::Input; @@ -18,6 +20,7 @@ use crate::session::Config; use crate::session::{civilizations, resolve_config}; use crate::tmux::AvailableTools; use crate::tui::components::{DirPicker, DirPickerResult, ListPicker, ListPickerResult}; +use path_input::PathCompletionCycle; pub(super) struct FieldHelp { pub(super) name: &'static str, @@ -99,6 +102,7 @@ pub struct NewSessionData { /// Spinner frames for loading animation pub(super) const SPINNER_FRAMES: &[&str] = &["◐", "◓", "◑", "◒"]; +const PATH_FIELD: usize = 1; pub struct NewSessionDialog { pub(super) profile: String, @@ -153,6 +157,10 @@ pub struct NewSessionDialog { pub(super) current_hook: Option, /// Accumulated output lines from hook execution pub(super) hook_output: Vec, + /// Temporary highlight state for invalid path input. + pub(super) path_invalid_flash_until: Option, + /// State for cycling path autocomplete candidates with repeated Tab. + path_completion_cycle: Option, } /// Shared logic for handling key events in an editable list (env keys or env values). @@ -353,6 +361,8 @@ impl NewSessionDialog { has_hooks: false, current_hook: None, hook_output: Vec::new(), + path_invalid_flash_until: None, + path_completion_cycle: None, } } @@ -392,9 +402,24 @@ impl NewSessionDialog { self.loading } - /// Advance the spinner animation frame. Call this periodically when loading. - pub fn tick(&mut self) { - self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); + /// Advance dialog timers (spinner and transient highlights). + /// Returns true when visual state changed and the UI should redraw. + pub fn tick(&mut self) -> bool { + let mut changed = false; + + if self.loading { + self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); + changed = true; + } + + if let Some(until) = self.path_invalid_flash_until { + if Instant::now() >= until { + self.path_invalid_flash_until = None; + changed = true; + } + } + + changed } #[cfg(test)] @@ -449,6 +474,8 @@ impl NewSessionDialog { has_hooks: false, current_hook: None, hook_output: Vec::new(), + path_invalid_flash_until: None, + path_completion_cycle: None, } } @@ -495,6 +522,8 @@ impl NewSessionDialog { has_hooks: false, current_hook: None, hook_output: Vec::new(), + path_invalid_flash_until: None, + path_completion_cycle: None, } } @@ -537,6 +566,7 @@ impl NewSessionDialog { match self.dir_picker.handle_key(key) { DirPickerResult::Selected(path) => { self.path = Input::new(path); + self.clear_path_completion_cycle(); } DirPickerResult::Cancelled | DirPickerResult::Continue => {} } @@ -614,7 +644,7 @@ impl NewSessionDialog { // Ctrl+P opens a context-sensitive picker if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) { - if self.focused_field == 1 { + if self.focused_field == PATH_FIELD { let path_value = self.path.value().trim().to_string(); self.dir_picker.activate(&path_value); return DialogResult::Continue; @@ -634,6 +664,10 @@ impl NewSessionDialog { } } + if self.handle_path_shortcuts(key) { + return DialogResult::Continue; + } + match key.code { KeyCode::Char('?') => { self.show_help = true; @@ -691,10 +725,16 @@ impl NewSessionDialog { }) } KeyCode::Tab | KeyCode::Down => { + if self.focused_field == PATH_FIELD { + self.clear_path_completion_cycle(); + } self.focused_field = (self.focused_field + 1) % max_field; DialogResult::Continue } KeyCode::BackTab | KeyCode::Up => { + if self.focused_field == PATH_FIELD { + self.clear_path_completion_cycle(); + } self.focused_field = if self.focused_field == 0 { max_field - 1 } else { @@ -771,6 +811,10 @@ impl NewSessionDialog { self.current_input_mut() .handle_event(&crossterm::event::Event::Key(key)); self.error_message = None; + if self.focused_field == PATH_FIELD { + self.path_invalid_flash_until = None; + self.clear_path_completion_cycle(); + } } DialogResult::Continue } @@ -840,7 +884,7 @@ impl NewSessionDialog { match self.focused_field { 0 => &mut self.title, - 1 => &mut self.path, + PATH_FIELD => &mut self.path, n if n == worktree_field => &mut self.worktree_branch, n if n == sandbox_image_field => &mut self.sandbox_image, n if n == group_field => &mut self.group, diff --git a/src/tui/dialogs/new_session/path_input.rs b/src/tui/dialogs/new_session/path_input.rs new file mode 100644 index 000000000..c54c97769 --- /dev/null +++ b/src/tui/dialogs/new_session/path_input.rs @@ -0,0 +1,314 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +use super::{NewSessionDialog, PATH_FIELD}; + +const PATH_INVALID_FLASH_DURATION: Duration = Duration::from_millis(300); + +enum PathAutocompleteOutcome { + /// No directory matches were found; caller may fall back to normal Tab behavior. + NoCandidates, + /// Matches exist but input text did not change. + NoChange, + /// Input text was updated from completion. + Changed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PathCompletionCycle { + pub(super) parent_prefix: String, + pub(super) suffix: String, + pub(super) candidates: Vec, + /// None means we're showing only the common prefix, not a concrete candidate yet. + pub(super) displayed_index: Option, +} + +fn char_to_byte_idx(value: &str, char_idx: usize) -> usize { + value + .char_indices() + .nth(char_idx) + .map(|(idx, _)| idx) + .unwrap_or(value.len()) +} + +fn longest_common_prefix(values: &[String]) -> String { + if values.is_empty() { + return String::new(); + } + + let mut prefix = values[0].clone(); + for value in &values[1..] { + while !value.starts_with(&prefix) { + if prefix.pop().is_none() { + break; + } + } + if prefix.is_empty() { + break; + } + } + prefix +} + +fn path_completion_base(parent_prefix: &str) -> Option { + if parent_prefix.is_empty() { + return Some(PathBuf::from(".")); + } + + let trimmed = parent_prefix.trim_end_matches('/'); + if trimmed.is_empty() { + return Some(PathBuf::from("/")); + } + + if trimmed == "~" { + return dirs::home_dir(); + } + + if let Some(stripped) = trimmed.strip_prefix("~/") { + return dirs::home_dir().map(|home| home.join(stripped)); + } + + Some(PathBuf::from(trimmed)) +} + +impl NewSessionDialog { + pub(super) fn handle_path_shortcuts(&mut self, key: KeyEvent) -> bool { + if self.focused_field != PATH_FIELD { + return false; + } + + if key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE { + match self.autocomplete_path_segment() { + PathAutocompleteOutcome::NoCandidates => { + self.clear_path_completion_cycle(); + if self.is_path_invalid() { + self.trigger_path_invalid_flash(); + return true; + } + return false; + } + PathAutocompleteOutcome::NoChange => return true, + PathAutocompleteOutcome::Changed => { + self.error_message = None; + self.path_invalid_flash_until = None; + return true; + } + } + } + + if matches!(key.code, KeyCode::Home) + || (key.code == KeyCode::Char('a') && key.modifiers.contains(KeyModifiers::CONTROL)) + { + self.move_path_cursor_to(0); + self.error_message = None; + self.path_invalid_flash_until = None; + self.clear_path_completion_cycle(); + return true; + } + + if (key.code == KeyCode::Left && key.modifiers.contains(KeyModifiers::CONTROL)) + || (key.code == KeyCode::Char('b') && key.modifiers.contains(KeyModifiers::ALT)) + { + self.move_path_cursor_to_previous_segment(); + self.error_message = None; + self.path_invalid_flash_until = None; + self.clear_path_completion_cycle(); + return true; + } + + false + } + + fn move_path_cursor_to(&mut self, target_char_idx: usize) { + let char_len = self.path.value().chars().count(); + let target = target_char_idx.min(char_len); + let current = self.path.visual_cursor().min(char_len); + + if target < current { + for _ in 0..(current - target) { + self.path + .handle_event(&crossterm::event::Event::Key(KeyEvent::new( + KeyCode::Left, + KeyModifiers::NONE, + ))); + } + } else if target > current { + for _ in 0..(target - current) { + self.path + .handle_event(&crossterm::event::Event::Key(KeyEvent::new( + KeyCode::Right, + KeyModifiers::NONE, + ))); + } + } + } + + fn move_path_cursor_to_previous_segment(&mut self) { + let chars: Vec = self.path.value().chars().collect(); + let mut cursor = self.path.visual_cursor().min(chars.len()); + if cursor == 0 { + return; + } + + while cursor > 0 && chars[cursor - 1] == '/' { + cursor -= 1; + } + while cursor > 0 && chars[cursor - 1] != '/' { + cursor -= 1; + } + + self.move_path_cursor_to(cursor); + } + + fn set_path_value_with_cursor(&mut self, value: String, cursor_char_idx: usize) { + self.path = Input::new(value); + let total_chars = self.path.value().chars().count(); + let target = cursor_char_idx.min(total_chars); + let left_steps = total_chars.saturating_sub(target); + + for _ in 0..left_steps { + self.path + .handle_event(&crossterm::event::Event::Key(KeyEvent::new( + KeyCode::Left, + KeyModifiers::NONE, + ))); + } + } + + fn autocomplete_path_segment(&mut self) -> PathAutocompleteOutcome { + let value = self.path.value().to_string(); + let cursor_char = self.path.visual_cursor().min(value.chars().count()); + let cursor_byte = char_to_byte_idx(&value, cursor_char); + + let segment_start = value[..cursor_byte].rfind('/').map_or(0, |idx| idx + 1); + let segment_end = value[cursor_byte..] + .find('/') + .map_or(value.len(), |offset| cursor_byte + offset); + + let parent_prefix = &value[..segment_start]; + let current_segment = &value[segment_start..cursor_byte]; + let suffix = &value[segment_end..]; + let context_parent = parent_prefix.to_string(); + let context_suffix = suffix.to_string(); + + if let Some(cycle) = self.path_completion_cycle.as_mut() { + if cycle.parent_prefix == context_parent + && cycle.suffix == context_suffix + && cycle.candidates.len() > 1 + { + let next_index = cycle + .displayed_index + .map(|idx| (idx + 1) % cycle.candidates.len()) + .unwrap_or(0); + cycle.displayed_index = Some(next_index); + let replacement = cycle.candidates[next_index].clone(); + + let mut completed = String::with_capacity(value.len() + replacement.len()); + completed.push_str(parent_prefix); + completed.push_str(&replacement); + completed.push_str(suffix); + + if completed == value { + return PathAutocompleteOutcome::NoChange; + } + + let cursor_after_completion = + parent_prefix.chars().count() + replacement.chars().count(); + self.set_path_value_with_cursor(completed, cursor_after_completion); + return PathAutocompleteOutcome::Changed; + } + } + + let Some(base_dir) = path_completion_base(parent_prefix) else { + self.clear_path_completion_cycle(); + return PathAutocompleteOutcome::NoCandidates; + }; + + let include_hidden = current_segment.starts_with('.'); + let mut matches = Vec::new(); + let Ok(entries) = std::fs::read_dir(base_dir) else { + self.clear_path_completion_cycle(); + return PathAutocompleteOutcome::NoCandidates; + }; + + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + let file_name = entry.file_name(); + let Some(name) = file_name.to_str() else { + continue; + }; + if !include_hidden && name.starts_with('.') { + continue; + } + if name.starts_with(current_segment) { + matches.push(name.to_string()); + } + } + + if matches.is_empty() { + self.clear_path_completion_cycle(); + return PathAutocompleteOutcome::NoCandidates; + } + matches.sort(); + + let replacement = if matches.len() == 1 { + self.clear_path_completion_cycle(); + let mut segment = matches[0].clone(); + if suffix.is_empty() { + segment.push('/'); + } + segment + } else { + let common_prefix = longest_common_prefix(&matches); + self.path_completion_cycle = Some(PathCompletionCycle { + parent_prefix: context_parent, + suffix: context_suffix, + candidates: matches.clone(), + displayed_index: None, + }); + if common_prefix.chars().count() > current_segment.chars().count() { + common_prefix + } else if let Some(new_cycle) = self.path_completion_cycle.as_mut() { + new_cycle.displayed_index = Some(0); + new_cycle.candidates[0].clone() + } else { + matches[0].clone() + } + }; + + let mut completed = String::with_capacity(value.len() + replacement.len()); + completed.push_str(parent_prefix); + completed.push_str(&replacement); + completed.push_str(suffix); + + if completed == value { + return PathAutocompleteOutcome::NoChange; + } + + let cursor_after_completion = parent_prefix.chars().count() + replacement.chars().count(); + self.set_path_value_with_cursor(completed, cursor_after_completion); + PathAutocompleteOutcome::Changed + } + + pub(super) fn clear_path_completion_cycle(&mut self) { + self.path_completion_cycle = None; + } + + fn is_path_invalid(&self) -> bool { + let path = self.path.value().trim(); + path.is_empty() || !Path::new(path).is_dir() + } + + fn trigger_path_invalid_flash(&mut self) { + self.path_invalid_flash_until = Some(Instant::now() + PATH_INVALID_FLASH_DURATION); + } + + pub(super) fn is_path_invalid_flash_active(&self) -> bool { + self.path_invalid_flash_until.is_some() + } +} diff --git a/src/tui/dialogs/new_session/render.rs b/src/tui/dialogs/new_session/render.rs index 22a1ea6fe..69668b733 100644 --- a/src/tui/dialogs/new_session/render.rs +++ b/src/tui/dialogs/new_session/render.rs @@ -3,7 +3,7 @@ use ratatui::prelude::*; use ratatui::widgets::*; -use super::{NewSessionDialog, FIELD_HELP, HELP_DIALOG_WIDTH, SPINNER_FRAMES}; +use super::{NewSessionDialog, FIELD_HELP, HELP_DIALOG_WIDTH, PATH_FIELD, SPINNER_FRAMES}; use crate::tui::components::render_text_field; use crate::tui::styles::Theme; @@ -107,29 +107,25 @@ impl NewSessionDialog { let mut ci = 0; // chunk index // Title, Path (always visible) - let path_placeholder = if self.focused_field == 1 { + let path_placeholder = if self.focused_field == PATH_FIELD { Some("(Ctrl+P to browse directories)") } else { None }; - let text_fields: [(&str, &tui_input::Input, Option<&str>); 2] = [ - ("Title:", &self.title, Some("(random civ)")), - ("Path:", &self.path, path_placeholder), - ]; + render_text_field( + frame, + chunks[ci], + "Title:", + &self.title, + self.focused_field == 0, + Some("(random civ)"), + theme, + ); + ci += 1; - for (idx, (label, input, placeholder)) in text_fields.iter().enumerate() { - render_text_field( - frame, - chunks[ci], - label, - input, - idx == self.focused_field, - *placeholder, - theme, - ); - ci += 1; - } + self.render_path_field(frame, chunks[ci], path_placeholder, theme); + ci += 1; // Tool (always shown, interactive or read-only) let yolo_mode_field = if has_tool_selection { 3 } else { 2 }; @@ -361,15 +357,22 @@ impl NewSessionDialog { .wrap(Wrap { trim: true }); frame.render_widget(error_paragraph, chunks[hint_chunk]); } else { - let mut hint_spans = vec![ - Span::styled("Tab", Style::default().fg(theme.hint)), - Span::raw(" next "), - ]; + let mut hint_spans = Vec::new(); + if self.focused_field != PATH_FIELD { + hint_spans.push(Span::styled("Tab", Style::default().fg(theme.hint))); + hint_spans.push(Span::raw(" next ")); + } if has_tool_selection { hint_spans.push(Span::styled("←/→", Style::default().fg(theme.hint))); hint_spans.push(Span::raw(" tool ")); } - if self.focused_field == 1 { + if self.focused_field == PATH_FIELD { + hint_spans.push(Span::styled("Tab", Style::default().fg(theme.hint))); + hint_spans.push(Span::raw(" complete ")); + hint_spans.push(Span::styled("C-←/M-b", Style::default().fg(theme.hint))); + hint_spans.push(Span::raw(" prev seg ")); + hint_spans.push(Span::styled("Home/C-a", Style::default().fg(theme.hint))); + hint_spans.push(Span::raw(" start ")); hint_spans.push(Span::styled("C-p", Style::default().fg(theme.hint))); hint_spans.push(Span::raw(" browse ")); } @@ -407,6 +410,75 @@ impl NewSessionDialog { } } + fn render_path_field( + &self, + frame: &mut Frame, + area: Rect, + placeholder: Option<&str>, + theme: &Theme, + ) { + let is_focused = self.focused_field == PATH_FIELD; + let flashing_invalid = self.is_path_invalid_flash_active(); + + let label_color = if flashing_invalid { + theme.error + } else if is_focused { + theme.accent + } else { + theme.text + }; + let value_color = if flashing_invalid { + theme.error + } else if is_focused { + theme.accent + } else { + theme.text + }; + + let label_style = if is_focused { + Style::default().fg(label_color).underlined() + } else { + Style::default().fg(label_color) + }; + let value_style = Style::default().fg(value_color); + + let value = self.path.value(); + let mut spans = vec![Span::styled("Path:", label_style), Span::raw(" ")]; + + if value.is_empty() && !is_focused { + if let Some(placeholder_text) = placeholder { + spans.push(Span::styled(placeholder_text, value_style)); + } + } else if is_focused { + let cursor_pos = self.path.visual_cursor(); + let cursor_style = if flashing_invalid { + Style::default().fg(theme.background).bg(theme.error) + } else { + Style::default().fg(theme.background).bg(theme.accent) + }; + + let before: String = value.chars().take(cursor_pos).collect(); + let cursor_char: String = value + .chars() + .nth(cursor_pos) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()); + let after: String = value.chars().skip(cursor_pos + 1).collect(); + + if !before.is_empty() { + spans.push(Span::styled(before, value_style)); + } + spans.push(Span::styled(cursor_char, cursor_style)); + if !after.is_empty() { + spans.push(Span::styled(after, value_style)); + } + } else { + spans.push(Span::styled(value, value_style)); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + fn render_env_field(&self, frame: &mut Frame, area: Rect, env_field: usize, theme: &Theme) { let is_focused = self.focused_field == env_field; let label_style = if is_focused { diff --git a/src/tui/dialogs/new_session/tests.rs b/src/tui/dialogs/new_session/tests.rs index f2e8de839..c0f6525d9 100644 --- a/src/tui/dialogs/new_session/tests.rs +++ b/src/tui/dialogs/new_session/tests.rs @@ -1,28 +1,45 @@ use super::*; use crate::session::{merge_configs, Config, ProfileConfig, SessionConfigOverride}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::fs; + +const TEST_PATH: &str = "/__aoe_nonexistent__/project"; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } +fn ctrl_key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) +} + +fn alt_key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::ALT) +} + fn shift_key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::SHIFT) } fn single_tool_dialog() -> NewSessionDialog { - NewSessionDialog::new_with_tools(vec!["claude"], "/tmp/project".to_string()) + NewSessionDialog::new_with_tools(vec!["claude"], TEST_PATH.to_string()) } fn multi_tool_dialog() -> NewSessionDialog { - NewSessionDialog::new_with_tools(vec!["claude", "opencode"], "/tmp/project".to_string()) + NewSessionDialog::new_with_tools(vec!["claude", "opencode"], TEST_PATH.to_string()) +} + +fn set_valid_empty_path(dialog: &mut NewSessionDialog) -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + dialog.path = Input::new(format!("{}/", tmp.path().display())); + tmp } #[test] fn test_initial_state() { let dialog = single_tool_dialog(); assert_eq!(dialog.title.value(), ""); - assert_eq!(dialog.path.value(), "/tmp/project"); + assert_eq!(dialog.path.value(), TEST_PATH); assert_eq!(dialog.group.value(), ""); assert_eq!(dialog.focused_field, 0); assert_eq!(dialog.tool_index, 0); @@ -48,7 +65,7 @@ fn test_enter_submits_with_auto_title() { "Expected a civilization name, got: {}", data.title ); - assert_eq!(data.path, "/tmp/project"); + assert_eq!(data.path, TEST_PATH); assert_eq!(data.group, ""); assert_eq!(data.tool, "claude"); } @@ -72,6 +89,7 @@ fn test_enter_preserves_custom_title() { #[test] fn test_tab_cycles_fields_single_tool() { let mut dialog = single_tool_dialog(); + let _tmp = set_valid_empty_path(&mut dialog); assert_eq!(dialog.focused_field, 0); dialog.handle_key(key(KeyCode::Tab)); @@ -93,6 +111,7 @@ fn test_tab_cycles_fields_single_tool() { #[test] fn test_tab_cycles_fields_single_tool_with_worktree() { let mut dialog = single_tool_dialog(); + let _tmp = set_valid_empty_path(&mut dialog); dialog.worktree_branch = Input::new("feature".to_string()); assert_eq!(dialog.focused_field, 0); @@ -118,6 +137,7 @@ fn test_tab_cycles_fields_single_tool_with_worktree() { #[test] fn test_tab_cycles_fields_multi_tool() { let mut dialog = multi_tool_dialog(); + let _tmp = set_valid_empty_path(&mut dialog); assert_eq!(dialog.focused_field, 0); dialog.handle_key(key(KeyCode::Tab)); @@ -174,7 +194,208 @@ fn test_char_input_to_path() { dialog.focused_field = 1; dialog.handle_key(key(KeyCode::Char('/'))); dialog.handle_key(key(KeyCode::Char('a'))); - assert_eq!(dialog.path.value(), "/tmp/project/a"); + assert_eq!(dialog.path.value(), format!("{TEST_PATH}/a")); +} + +#[test] +fn test_tab_autocompletes_path_with_single_directory_match() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + fs::create_dir(tmp.path().join("project-alpha")).expect("failed to create directory"); + fs::write(tmp.path().join("project-file"), "not a directory").expect("failed to write file"); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(format!("{}/pro", tmp.path().display())); + + dialog.handle_key(key(KeyCode::Tab)); + + assert_eq!(dialog.focused_field, 1); + assert_eq!( + dialog.path.value(), + format!("{}/project-alpha/", tmp.path().display()) + ); +} + +#[test] +fn test_tab_autocompletes_path_to_common_prefix_for_multiple_matches() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + fs::create_dir(tmp.path().join("client-api")).expect("failed to create directory"); + fs::create_dir(tmp.path().join("client-web")).expect("failed to create directory"); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(format!("{}/cl", tmp.path().display())); + + dialog.handle_key(key(KeyCode::Tab)); + + assert_eq!(dialog.focused_field, 1); + assert_eq!( + dialog.path.value(), + format!("{}/client-", tmp.path().display()) + ); +} + +#[test] +fn test_tab_moves_to_next_field_when_no_path_completion_exists() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + // Empty directory: path is valid, but there are no completion candidates. + let valid_empty_path = format!("{}/", tmp.path().display()); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(valid_empty_path.clone()); + + dialog.handle_key(key(KeyCode::Tab)); + + assert_eq!(dialog.focused_field, 2); + assert_eq!(dialog.path.value(), valid_empty_path); +} + +#[test] +fn test_tab_on_invalid_path_does_not_switch_field_and_flashes_path() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let invalid_path = format!("{}/missing/subdir", tmp.path().display()); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(invalid_path.clone()); + + dialog.handle_key(key(KeyCode::Tab)); + + assert_eq!(dialog.focused_field, 1); + assert_eq!(dialog.path.value(), invalid_path); + assert!(dialog.is_path_invalid_flash_active()); +} + +#[test] +fn test_invalid_path_flash_expires_after_tick() { + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new("/does/not/exist".to_string()); + dialog.handle_key(key(KeyCode::Tab)); + assert!(dialog.is_path_invalid_flash_active()); + + dialog.path_invalid_flash_until = + Some(std::time::Instant::now() - std::time::Duration::from_millis(1)); + assert!(dialog.tick()); + assert!(!dialog.is_path_invalid_flash_active()); +} + +#[test] +fn test_tab_does_not_switch_field_when_path_has_candidates_without_extension() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + fs::create_dir(tmp.path().join("alpha")).expect("failed to create directory"); + fs::create_dir(tmp.path().join("beta")).expect("failed to create directory"); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(format!("{}/", tmp.path().display())); + + dialog.handle_key(key(KeyCode::Tab)); + + assert_eq!(dialog.focused_field, 1); + assert_eq!( + dialog.path.value(), + format!("{}/alpha", tmp.path().display()) + ); +} + +#[test] +fn test_tab_cycles_multiple_path_candidates() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + fs::create_dir(tmp.path().join("client-api")).expect("failed to create directory"); + fs::create_dir(tmp.path().join("client-web")).expect("failed to create directory"); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(format!("{}/cl", tmp.path().display())); + + dialog.handle_key(key(KeyCode::Tab)); + assert_eq!( + dialog.path.value(), + format!("{}/client-", tmp.path().display()) + ); + + dialog.handle_key(key(KeyCode::Tab)); + assert_eq!( + dialog.path.value(), + format!("{}/client-api", tmp.path().display()) + ); + + dialog.handle_key(key(KeyCode::Tab)); + assert_eq!( + dialog.path.value(), + format!("{}/client-web", tmp.path().display()) + ); + + dialog.handle_key(key(KeyCode::Tab)); + assert_eq!( + dialog.path.value(), + format!("{}/client-api", tmp.path().display()) + ); +} + +#[test] +fn test_typing_key_accepts_selected_completion_and_resets_cycle_context() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + fs::create_dir(tmp.path().join("client-api")).expect("failed to create directory"); + fs::create_dir(tmp.path().join("client-web")).expect("failed to create directory"); + fs::create_dir(tmp.path().join("client-api").join("src")).expect("failed to create directory"); + + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new(format!("{}/cl", tmp.path().display())); + + dialog.handle_key(key(KeyCode::Tab)); // common prefix + dialog.handle_key(key(KeyCode::Tab)); // client-api + dialog.handle_key(key(KeyCode::Char('/'))); // accept selection and keep editing + + assert_eq!( + dialog.path.value(), + format!("{}/client-api/", tmp.path().display()) + ); + + dialog.handle_key(key(KeyCode::Tab)); // should complete inside selected directory, not cycle siblings + assert_eq!( + dialog.path.value(), + format!("{}/client-api/src/", tmp.path().display()) + ); +} + +#[test] +fn test_ctrl_left_jumps_to_previous_path_segment() { + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new("/tmp/alpha/beta".to_string()); + + dialog.handle_key(ctrl_key(KeyCode::Left)); + dialog.handle_key(key(KeyCode::Char('X'))); + + assert_eq!(dialog.path.value(), "/tmp/alpha/Xbeta"); +} + +#[test] +fn test_alt_b_jumps_to_previous_path_segment() { + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new("/tmp/alpha/beta".to_string()); + + dialog.handle_key(alt_key(KeyCode::Char('b'))); + dialog.handle_key(key(KeyCode::Char('X'))); + + assert_eq!(dialog.path.value(), "/tmp/alpha/Xbeta"); +} + +#[test] +fn test_ctrl_a_jumps_to_start_of_path() { + let mut dialog = single_tool_dialog(); + dialog.focused_field = 1; + dialog.path = Input::new("/tmp/alpha/beta".to_string()); + + dialog.handle_key(ctrl_key(KeyCode::Char('a'))); + dialog.handle_key(key(KeyCode::Char('X'))); + + assert_eq!(dialog.path.value(), "X/tmp/alpha/beta"); } #[test] @@ -330,6 +551,7 @@ fn test_submit_respects_create_new_branch() { #[test] fn test_new_branch_field_hidden_without_worktree() { let mut dialog = single_tool_dialog(); + let _tmp = set_valid_empty_path(&mut dialog); assert_eq!(dialog.focused_field, 0); // Tab through all fields: title(0) -> path(1) -> yolo(2) -> worktree(3) -> group(4) -> wrap to 0 @@ -362,6 +584,7 @@ fn test_sandbox_image_initialized_with_effective_default() { #[test] fn test_tab_includes_sandbox_options_when_sandbox_enabled() { let mut dialog = multi_tool_dialog(); + let _tmp = set_valid_empty_path(&mut dialog); dialog.docker_available = true; dialog.sandbox_enabled = true; @@ -394,6 +617,7 @@ fn test_tab_includes_sandbox_options_when_sandbox_enabled() { #[test] fn test_tab_skips_sandbox_image_when_sandbox_disabled() { let mut dialog = multi_tool_dialog(); + let _tmp = set_valid_empty_path(&mut dialog); dialog.docker_available = true; dialog.sandbox_enabled = false; diff --git a/src/tui/home/mod.rs b/src/tui/home/mod.rs index e3d0b8c54..07bf55689 100644 --- a/src/tui/home/mod.rs +++ b/src/tui/home/mod.rs @@ -474,17 +474,26 @@ impl HomeView { self.creation_poller.is_pending() } - /// Tick the dialog spinner animation if loading, and drain hook progress - pub fn tick_dialog(&mut self) { + /// Tick dialog animations/timers and drain hook progress. + /// Returns true when a redraw is needed. + pub fn tick_dialog(&mut self) -> bool { + let mut changed = false; + if let Some(dialog) = &mut self.new_dialog { + if dialog.tick() { + changed = true; + } + if dialog.is_loading() { - dialog.tick(); // Drain all pending hook progress messages while let Some(progress) = self.creation_poller.try_recv_progress() { dialog.push_hook_progress(progress); + changed = true; } } } + + changed } pub fn has_dialog(&self) -> bool {