Skip to content

Commit e458628

Browse files
Merge pull request #163 from jhult/feat/file-injection
feat(tui): `@file` injection with typeahead autocomplete
2 parents 9fecd0f + 57d629a commit e458628

7 files changed

Lines changed: 686 additions & 182 deletions

File tree

src-rust/crates/cli/src/main.rs

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,17 +1947,22 @@ async fn run_interactive(
19471947
app.notifications.tick();
19481948

19491949
// Process file injection dialog outcome (if any)
1950-
if let Some((outcome, pending_input, _pending_imgs)) = app.file_injection_dialog.take_outcome() {
1950+
if let Some((outcome, pending_input, pending_imgs)) = app.file_injection_dialog.take_outcome() {
19511951
use claurst_tui::FileInjectionOutcome;
19521952

19531953
if matches!(outcome, FileInjectionOutcome::Abort) {
19541954
// Abort: input already restored to prompt by app.rs handler
19551955
continue;
19561956
}
19571957

1958-
// InjectAll or SkipOversized: restore input to prompt for resubmission
1959-
// Images attached when dialog was shown are discarded; user can re-attach if needed
1958+
// InjectAll: bypass size limit on resubmission, restore stashed input+images,
1959+
// then synthesize Enter to send immediately.
1960+
app.file_injection_force = true;
1961+
for img in pending_imgs {
1962+
app.prompt_input.add_image(img);
1963+
}
19601964
app.set_prompt_text(pending_input);
1965+
app.pending_auto_submit = true;
19611966
}
19621967

19631968
// Draw the UI
@@ -2052,6 +2057,18 @@ async fn run_interactive(
20522057
continue;
20532058
}
20542059
if key.code == KeyCode::Enter && !app.is_streaming && !any_dialog_open {
2060+
// If a file-ref suggestion is active, accept it instead of submitting.
2061+
if !app.prompt_input.suggestions.is_empty()
2062+
&& app.prompt_input.suggestion_index.is_some()
2063+
&& app.prompt_input.suggestions.get(app.prompt_input.suggestion_index.unwrap())
2064+
.map(|s| s.source == claurst_tui::prompt_input::TypeaheadSource::FileRef)
2065+
.unwrap_or(false)
2066+
{
2067+
app.prompt_input.accept_suggestion();
2068+
app.prompt_input.insert_char(' ');
2069+
app.refresh_prompt_input();
2070+
continue;
2071+
}
20552072
// If a slash-command suggestion is active, accept and execute immediately.
20562073
if !app.prompt_input.suggestions.is_empty()
20572074
&& app.prompt_input.suggestion_index.is_some()
@@ -2430,7 +2447,7 @@ async fn run_interactive(
24302447
}
24312448

24322449
// Fire UserPromptSubmit hook (non-blocking)
2433-
if !config.hooks.is_empty() {
2450+
if !cmd_ctx.config.hooks.is_empty() {
24342451
let hook_ctx = claurst_core::hooks::HookContext {
24352452
event: "UserPromptSubmit".to_string(),
24362453
tool_name: None,
@@ -2440,7 +2457,7 @@ async fn run_interactive(
24402457
session_id: Some(tool_ctx.session_id.clone()),
24412458
};
24422459
claurst_core::hooks::run_hooks(
2443-
&config.hooks,
2460+
&cmd_ctx.config.hooks,
24442461
claurst_core::config::HookEvent::UserPromptSubmit,
24452462
&hook_ctx,
24462463
&tool_ctx.working_dir,
@@ -2452,27 +2469,46 @@ async fn run_interactive(
24522469
let pending_imgs = app.prompt_input.clear_images();
24532470

24542471
// Check for file injection if enabled
2455-
if config.file_injection_enabled {
2472+
if app.config.file_injection_enabled {
24562473
use claurst_tui::file_injection::parse_at_refs;
24572474

2458-
let (within_limit, oversized) = parse_at_refs(&input, &tool_ctx.working_dir, config.file_injection_max_size);
2475+
// file_injection_force is set when user chose "inject anyways" in the
2476+
// warning dialog — pass limit 0 so all files are treated as within
2477+
// limit. Also drop any directory refs silently on force re-submit so
2478+
// they don't loop back to the directory warning.
2479+
let was_force = app.file_injection_force;
2480+
let effective_limit = if app.file_injection_force {
2481+
app.file_injection_force = false;
2482+
0
2483+
} else {
2484+
app.config.file_injection_max_size
2485+
};
2486+
let (within_limit, mut oversized) = parse_at_refs(&input, &tool_ctx.working_dir, effective_limit);
2487+
if was_force {
2488+
oversized.retain(|f| !matches!(f.issue, Some(claurst_tui::AtFileIssue::IsDirectory)));
2489+
}
24592490

24602491
if !oversized.is_empty() {
2461-
// Show dialog with oversized files
2462-
let oversized_summaries: Vec<(String, usize, String)> = oversized
2492+
// Show either the directory warning or the file warning, never both.
2493+
// Directories take precedence: if any are present, show only those.
2494+
let has_dirs = oversized.iter().any(|f| matches!(f.issue, Some(claurst_tui::AtFileIssue::IsDirectory)));
2495+
let oversized_summaries: Vec<(String, usize, claurst_tui::AtFileIssue)> = oversized
24632496
.iter()
2464-
.map(|f| {
2465-
let issue_str = match &f.issue {
2466-
Some(claurst_tui::AtFileIssue::TooLarge(kb)) => format!("TooLarge: {} KB", kb),
2467-
Some(claurst_tui::AtFileIssue::Binary) => "Binary".to_string(),
2468-
Some(claurst_tui::AtFileIssue::Unreadable(e)) => e.clone(),
2469-
None => "Unknown".to_string(),
2470-
};
2471-
(f.path.display().to_string(), f.size_kb, issue_str)
2497+
.filter(|f| {
2498+
let is_dir = matches!(f.issue, Some(claurst_tui::AtFileIssue::IsDirectory));
2499+
if has_dirs { is_dir } else { !is_dir }
24722500
})
2501+
.filter_map(|f| f.issue.clone().map(|issue| (f.path.display().to_string(), f.size_kb, issue)))
24732502
.collect();
24742503

2475-
app.file_injection_dialog.show(input.clone(), pending_imgs, oversized_summaries);
2504+
app.file_injection_dialog.show(
2505+
input.clone(),
2506+
pending_imgs,
2507+
oversized_summaries,
2508+
app.config.file_injection_max_size,
2509+
Some(tool_ctx.working_dir.clone()),
2510+
);
2511+
app.set_prompt_text(input);
24762512
continue;
24772513
}
24782514

src-rust/crates/core/src/lib.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,11 +1558,22 @@ pub mod config {
15581558
config.skills.urls.push(u.clone());
15591559
}
15601560
}
1561-
// Copy file autocomplete and injection settings.
1562-
config.file_autocomplete_limit = self.file_autocomplete_limit;
1563-
config.file_autocomplete_show_hidden_files = self.file_autocomplete_show_hidden_files;
1564-
config.file_injection_enabled = self.file_injection_enabled;
1565-
config.file_injection_max_size = self.file_injection_max_size;
1561+
// Copy file autocomplete and injection settings from the top-level Settings
1562+
// fields, but only when they were explicitly set (differ from their defaults).
1563+
// If they're at defaults, the nested "config" section value (already in `config`
1564+
// via the clone above) takes precedence.
1565+
if self.file_autocomplete_limit != default_file_autocomplete_limit() {
1566+
config.file_autocomplete_limit = self.file_autocomplete_limit;
1567+
}
1568+
if self.file_autocomplete_show_hidden_files {
1569+
config.file_autocomplete_show_hidden_files = true;
1570+
}
1571+
if self.file_injection_enabled != default_true() {
1572+
config.file_injection_enabled = self.file_injection_enabled;
1573+
}
1574+
if self.file_injection_max_size != default_file_injection_max_size() {
1575+
config.file_injection_max_size = self.file_injection_max_size;
1576+
}
15661577
config
15671578
}
15681579

src-rust/crates/tui/src/app.rs

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,9 @@ pub struct App {
889889
/// File injection warning dialog.
890890
/// Shown when oversized or binary files are detected in @refs.
891891
pub file_injection_dialog: crate::file_injection_dialog::FileInjectionDialogState,
892+
/// When true, the next file injection size check uses limit 0 (no limit),
893+
/// letting files that were "allowed" through the warning dialog be injected.
894+
pub file_injection_force: bool,
892895
/// First-launch onboarding welcome dialog.
893896
pub onboarding_dialog: crate::onboarding_dialog::OnboardingDialogState,
894897
/// Effort-level picker (/effort with no args).
@@ -1340,6 +1343,7 @@ impl App {
13401343
bypass_permissions_dialog: crate::bypass_permissions_dialog::BypassPermissionsDialogState::new(),
13411344
bypass_permissions_dialog_shown: false,
13421345
file_injection_dialog: crate::file_injection_dialog::FileInjectionDialogState::new(),
1346+
file_injection_force: false,
13431347
onboarding_dialog: crate::onboarding_dialog::OnboardingDialogState::new(),
13441348
effort_picker: crate::effort_picker::EffortPickerState::new(),
13451349
key_input_dialog: crate::key_input_dialog::KeyInputDialogState::new(),
@@ -2647,9 +2651,16 @@ impl App {
26472651
self.history_index = self.prompt_input.history_pos;
26482652
}
26492653

2650-
fn refresh_prompt_input(&mut self) {
2654+
pub fn refresh_prompt_input(&mut self) {
26512655
self.prompt_input.mode = self.prompt_mode();
2652-
self.prompt_input.update_suggestions(PROMPT_SLASH_COMMANDS);
2656+
if self.file_injection_dialog.visible {
2657+
// Don't update suggestions while the injection dialog is open.
2658+
self.sync_legacy_prompt_fields();
2659+
return;
2660+
}
2661+
let file_autocomplete_limit = self.config.file_autocomplete_limit;
2662+
let file_autocomplete_show_hidden = self.config.file_autocomplete_show_hidden_files;
2663+
self.prompt_input.update_suggestions(PROMPT_SLASH_COMMANDS, file_autocomplete_limit, file_autocomplete_show_hidden);
26532664
self.sync_legacy_prompt_fields();
26542665
}
26552666

@@ -2882,25 +2893,27 @@ impl App {
28822893

28832894
// File injection dialog: shown when oversized files are detected in @refs.
28842895
if self.file_injection_dialog.visible {
2896+
let is_directory_only = self.file_injection_dialog.is_directory_only();
28852897
match key.code {
2886-
KeyCode::Char('i') | KeyCode::Char('I') => {
2887-
self.file_injection_dialog.selected = 0; // InjectAll
2888-
}
2889-
KeyCode::Char('s') | KeyCode::Char('S') => {
2890-
self.file_injection_dialog.selected = 1; // SkipOversized
2898+
KeyCode::Enter => {
2899+
if is_directory_only {
2900+
// Directories can't be injected; Enter = abort, restore input.
2901+
if let Some(input) = self.file_injection_dialog.pending_input.clone() {
2902+
self.set_prompt_text(input);
2903+
}
2904+
self.file_injection_dialog.dismiss();
2905+
} else {
2906+
// Enter = inject (Allow).
2907+
self.file_injection_dialog.selected = 0;
2908+
self.file_injection_dialog.confirm();
2909+
}
28912910
}
28922911
KeyCode::Esc => {
2893-
self.file_injection_dialog.selected = 2; // Abort
2894-
self.file_injection_dialog.confirm();
2895-
// Restore input to prompt when aborting
2896-
if let Some(input) = &self.file_injection_dialog.pending_input {
2897-
self.set_prompt_text(input.clone());
2912+
// Esc = abort, restore input.
2913+
if let Some(input) = self.file_injection_dialog.pending_input.clone() {
2914+
self.set_prompt_text(input);
28982915
}
2899-
}
2900-
KeyCode::Up | KeyCode::Char('k') => self.file_injection_dialog.select_prev(),
2901-
KeyCode::Down | KeyCode::Char('j') => self.file_injection_dialog.select_next(),
2902-
KeyCode::Enter => {
2903-
self.file_injection_dialog.confirm();
2916+
self.file_injection_dialog.dismiss();
29042917
}
29052918
_ => {}
29062919
}
@@ -4171,12 +4184,17 @@ impl App {
41714184
self.refresh_prompt_input();
41724185
}
41734186
KeyCode::Enter if !self.is_streaming => {
4174-
// If a slash-command suggestion is selected, accept it instead of submitting.
4187+
// If a suggestion is selected, accept it instead of submitting.
41754188
if !self.prompt_input.suggestions.is_empty()
41764189
&& self.prompt_input.suggestion_index.is_some()
4177-
&& self.prompt_input.text.starts_with('/')
41784190
{
4191+
let is_file_ref = self.prompt_input.suggestions
4192+
.get(self.prompt_input.suggestion_index.unwrap())
4193+
.map_or(false, |s| s.source == crate::prompt_input::TypeaheadSource::FileRef);
41794194
self.prompt_input.accept_suggestion();
4195+
if is_file_ref {
4196+
self.prompt_input.insert_char(' ');
4197+
}
41804198
self.refresh_prompt_input();
41814199
return false;
41824200
}
@@ -4209,7 +4227,7 @@ impl App {
42094227
// when the cursor is already on the first/last visual row
42104228
// (issue #149 follow-up).
42114229
KeyCode::Up => {
4212-
if !self.prompt_input.suggestions.is_empty() && self.prompt_input.text.starts_with('/') {
4230+
if !self.prompt_input.suggestions.is_empty() && (self.prompt_input.text.starts_with('/') || self.prompt_input.has_active_file_ref()) {
42134231
self.prompt_input.suggestion_prev();
42144232
} else {
42154233
let area = self.last_input_area.get();
@@ -4223,7 +4241,7 @@ impl App {
42234241
self.refresh_prompt_input();
42244242
}
42254243
KeyCode::Down => {
4226-
if !self.prompt_input.suggestions.is_empty() && self.prompt_input.text.starts_with('/') {
4244+
if !self.prompt_input.suggestions.is_empty() && (self.prompt_input.text.starts_with('/') || self.prompt_input.has_active_file_ref()) {
42274245
self.prompt_input.suggestion_next();
42284246
} else {
42294247
let area = self.last_input_area.get();
@@ -4673,29 +4691,53 @@ impl App {
46734691
self.refresh_global_search();
46744692
false
46754693
}
4676-
"submit" => !self.is_streaming,
4694+
"submit" => {
4695+
if !self.is_streaming {
4696+
if !self.prompt_input.suggestions.is_empty()
4697+
&& self.prompt_input.suggestion_index.is_some()
4698+
{
4699+
self.prompt_input.accept_suggestion();
4700+
self.refresh_prompt_input();
4701+
false
4702+
} else {
4703+
true
4704+
}
4705+
} else {
4706+
false
4707+
}
4708+
}
46774709
"historyPrev" => {
4678-
// Slash-command suggestions take priority over history.
4710+
// Suggestions (slash commands or file refs) take priority over cursor/history.
46794711
if !self.prompt_input.suggestions.is_empty()
4680-
&& self.prompt_input.text.starts_with('/')
4712+
&& (self.prompt_input.text.starts_with('/') || self.prompt_input.has_active_file_ref())
46814713
{
46824714
self.prompt_input.suggestion_prev();
46834715
self.refresh_prompt_input();
4684-
} else if !self.prompt_input.history.is_empty() {
4685-
self.prompt_input.history_up();
4716+
} else {
4717+
let width = self.last_input_area.get().width.saturating_sub(4) as usize;
4718+
let moved = !self.prompt_input.text.is_empty()
4719+
&& self.prompt_input.move_visual_up(width);
4720+
if !moved && !self.prompt_input.history.is_empty() {
4721+
self.prompt_input.history_up();
4722+
}
46864723
self.refresh_prompt_input();
46874724
}
46884725
false
46894726
}
46904727
"historyNext" => {
4691-
// Slash-command suggestions take priority over history.
4728+
// Suggestions (slash commands or file refs) take priority over cursor/history.
46924729
if !self.prompt_input.suggestions.is_empty()
4693-
&& self.prompt_input.text.starts_with('/')
4730+
&& (self.prompt_input.text.starts_with('/') || self.prompt_input.has_active_file_ref())
46944731
{
46954732
self.prompt_input.suggestion_next();
46964733
self.refresh_prompt_input();
4697-
} else if self.prompt_input.history_pos.is_some() {
4698-
self.prompt_input.history_down();
4734+
} else {
4735+
let width = self.last_input_area.get().width.saturating_sub(4) as usize;
4736+
let moved = !self.prompt_input.text.is_empty()
4737+
&& self.prompt_input.move_visual_down(width);
4738+
if !moved && self.prompt_input.history_pos.is_some() {
4739+
self.prompt_input.history_down();
4740+
}
46994741
self.refresh_prompt_input();
47004742
}
47014743
false

0 commit comments

Comments
 (0)