Skip to content

Commit 736980c

Browse files
njbrakeclaude
andcommitted
feat: add path autocomplete in new session pane
Replace Tab-triggered path completion cycling with fish-shell style ghost text. Dimmed completion text appears after the cursor and is accepted with Right arrow or End key. Tab keeps its normal field navigation role. - Ghost text shows directory completions when cursor is at end of input - Single match: shows remainder + / - Multiple matches: shows longest common prefix remainder - Recomputes reactively on every keystroke/cursor move - Includes staleness check to prevent stale ghost acceptance - Refactor tick() to return bool for smarter redraws Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d72398 commit 736980c

6 files changed

Lines changed: 689 additions & 43 deletions

File tree

src/tui/app.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,8 @@ impl App {
210210
refresh_needed = true;
211211
}
212212

213-
// Tick the dialog spinner if loading
214-
if self.home.is_creation_pending() {
215-
self.home.tick_dialog();
213+
// Tick dialog animations/timers (spinner, transient flashes)
214+
if self.home.tick_dialog() {
216215
refresh_needed = true;
217216
}
218217

src/tui/dialogs/new_session/mod.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
//! New session dialog
22
3+
mod path_input;
34
mod render;
45

56
#[cfg(test)]
67
mod tests;
78

89
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10+
use std::time::Instant;
911
use tui_input::backend::crossterm::EventHandler;
1012
use tui_input::Input;
1113

@@ -18,6 +20,7 @@ use crate::session::Config;
1820
use crate::session::{civilizations, resolve_config};
1921
use crate::tmux::AvailableTools;
2022
use crate::tui::components::{DirPicker, DirPickerResult, ListPicker, ListPickerResult};
23+
use path_input::PathGhostCompletion;
2124

2225
pub(super) struct FieldHelp {
2326
pub(super) name: &'static str,
@@ -99,6 +102,7 @@ pub struct NewSessionData {
99102

100103
/// Spinner frames for loading animation
101104
pub(super) const SPINNER_FRAMES: &[&str] = &["◐", "◓", "◑", "◒"];
105+
const PATH_FIELD: usize = 1;
102106

103107
pub struct NewSessionDialog {
104108
pub(super) profile: String,
@@ -153,6 +157,10 @@ pub struct NewSessionDialog {
153157
pub(super) current_hook: Option<String>,
154158
/// Accumulated output lines from hook execution
155159
pub(super) hook_output: Vec<String>,
160+
/// Temporary highlight state for invalid path input.
161+
pub(super) path_invalid_flash_until: Option<Instant>,
162+
/// Ghost text completion for the path field (fish-shell style).
163+
path_ghost: Option<PathGhostCompletion>,
156164
}
157165

158166
/// Shared logic for handling key events in an editable list (env keys or env values).
@@ -353,6 +361,8 @@ impl NewSessionDialog {
353361
has_hooks: false,
354362
current_hook: None,
355363
hook_output: Vec::new(),
364+
path_invalid_flash_until: None,
365+
path_ghost: None,
356366
}
357367
}
358368

@@ -392,9 +402,24 @@ impl NewSessionDialog {
392402
self.loading
393403
}
394404

395-
/// Advance the spinner animation frame. Call this periodically when loading.
396-
pub fn tick(&mut self) {
397-
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
405+
/// Advance dialog timers (spinner and transient highlights).
406+
/// Returns true when visual state changed and the UI should redraw.
407+
pub fn tick(&mut self) -> bool {
408+
let mut changed = false;
409+
410+
if self.loading {
411+
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
412+
changed = true;
413+
}
414+
415+
if let Some(until) = self.path_invalid_flash_until {
416+
if Instant::now() >= until {
417+
self.path_invalid_flash_until = None;
418+
changed = true;
419+
}
420+
}
421+
422+
changed
398423
}
399424

400425
#[cfg(test)]
@@ -449,6 +474,8 @@ impl NewSessionDialog {
449474
has_hooks: false,
450475
current_hook: None,
451476
hook_output: Vec::new(),
477+
path_invalid_flash_until: None,
478+
path_ghost: None,
452479
}
453480
}
454481

@@ -495,6 +522,8 @@ impl NewSessionDialog {
495522
has_hooks: false,
496523
current_hook: None,
497524
hook_output: Vec::new(),
525+
path_invalid_flash_until: None,
526+
path_ghost: None,
498527
}
499528
}
500529

@@ -537,6 +566,7 @@ impl NewSessionDialog {
537566
match self.dir_picker.handle_key(key) {
538567
DirPickerResult::Selected(path) => {
539568
self.path = Input::new(path);
569+
self.recompute_path_ghost();
540570
}
541571
DirPickerResult::Cancelled | DirPickerResult::Continue => {}
542572
}
@@ -614,7 +644,7 @@ impl NewSessionDialog {
614644

615645
// Ctrl+P opens a context-sensitive picker
616646
if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
617-
if self.focused_field == 1 {
647+
if self.focused_field == PATH_FIELD {
618648
let path_value = self.path.value().trim().to_string();
619649
self.dir_picker.activate(&path_value);
620650
return DialogResult::Continue;
@@ -634,6 +664,10 @@ impl NewSessionDialog {
634664
}
635665
}
636666

667+
if self.handle_path_shortcuts(key) {
668+
return DialogResult::Continue;
669+
}
670+
637671
match key.code {
638672
KeyCode::Char('?') => {
639673
self.show_help = true;
@@ -691,15 +725,27 @@ impl NewSessionDialog {
691725
})
692726
}
693727
KeyCode::Tab | KeyCode::Down => {
728+
if self.focused_field == PATH_FIELD {
729+
self.clear_path_ghost();
730+
}
694731
self.focused_field = (self.focused_field + 1) % max_field;
732+
if self.focused_field == PATH_FIELD {
733+
self.recompute_path_ghost();
734+
}
695735
DialogResult::Continue
696736
}
697737
KeyCode::BackTab | KeyCode::Up => {
738+
if self.focused_field == PATH_FIELD {
739+
self.clear_path_ghost();
740+
}
698741
self.focused_field = if self.focused_field == 0 {
699742
max_field - 1
700743
} else {
701744
self.focused_field - 1
702745
};
746+
if self.focused_field == PATH_FIELD {
747+
self.recompute_path_ghost();
748+
}
703749
DialogResult::Continue
704750
}
705751
KeyCode::Left | KeyCode::Right if self.focused_field == tool_field => {
@@ -771,6 +817,10 @@ impl NewSessionDialog {
771817
self.current_input_mut()
772818
.handle_event(&crossterm::event::Event::Key(key));
773819
self.error_message = None;
820+
if self.focused_field == PATH_FIELD {
821+
self.path_invalid_flash_until = None;
822+
self.recompute_path_ghost();
823+
}
774824
}
775825
DialogResult::Continue
776826
}
@@ -840,7 +890,7 @@ impl NewSessionDialog {
840890

841891
match self.focused_field {
842892
0 => &mut self.title,
843-
1 => &mut self.path,
893+
PATH_FIELD => &mut self.path,
844894
n if n == worktree_field => &mut self.worktree_branch,
845895
n if n == sandbox_image_field => &mut self.sandbox_image,
846896
n if n == group_field => &mut self.group,

0 commit comments

Comments
 (0)