Skip to content

Commit ff084ea

Browse files
moirahuangoz-agent
andcommitted
TUI: Align shell completions with GUI
Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 7cbb22d commit ff084ea

18 files changed

Lines changed: 840 additions & 312 deletions
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
use std::collections::HashMap;
2+
use std::future::Future;
3+
4+
use warp_completer::completer::{
5+
self, CompleterOptions, CompletionContext, CompletionsFallbackStrategy, MatchStrategy,
6+
SuggestionResults,
7+
};
8+
use warp_core::features::FeatureFlag;
9+
use warp_core::user_preferences::GetUserPreferences;
10+
use warpui::AppContext;
11+
12+
use crate::terminal::model::completions::ShellCompletion;
13+
use crate::terminal::model::session::Session;
14+
15+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16+
pub struct CompletionSourcePolicy {
17+
use_native_shell_completions: bool,
18+
force_native_shell_completions: bool,
19+
}
20+
21+
impl CompletionSourcePolicy {
22+
pub fn for_session(session: &Session, buffer_text: &str, ctx: &AppContext) -> Self {
23+
let force_native_shell_completions = ctx
24+
.private_user_preferences()
25+
.read_value("ForceNativeShellCompletions")
26+
.ok()
27+
.flatten()
28+
.and_then(|value| value.parse().ok())
29+
.unwrap_or(false);
30+
Self::from_inputs(
31+
FeatureFlag::NativeShellCompletions.is_enabled(),
32+
force_native_shell_completions,
33+
session.shell().supports_native_shell_completions(),
34+
buffer_text.contains('\n'),
35+
)
36+
}
37+
38+
fn from_inputs(
39+
native_shell_completions_enabled: bool,
40+
force_native_shell_completions: bool,
41+
shell_supports_native_completions: bool,
42+
is_multiline: bool,
43+
) -> Self {
44+
Self {
45+
use_native_shell_completions: (native_shell_completions_enabled
46+
|| force_native_shell_completions)
47+
&& shell_supports_native_completions
48+
&& !is_multiline,
49+
force_native_shell_completions,
50+
}
51+
}
52+
53+
pub fn should_request_native_shell_completions(self) -> bool {
54+
self.use_native_shell_completions
55+
}
56+
57+
pub fn fallback_strategy(
58+
self,
59+
fallback_when_native_is_unavailable: CompletionsFallbackStrategy,
60+
) -> CompletionsFallbackStrategy {
61+
if self.use_native_shell_completions {
62+
CompletionsFallbackStrategy::None
63+
} else {
64+
fallback_when_native_is_unavailable
65+
}
66+
}
67+
}
68+
69+
pub async fn completion_suggestions_with_native_fallback<T, F>(
70+
buffer_text: &str,
71+
cursor_position: usize,
72+
session_env_vars: Option<&HashMap<String, String>>,
73+
mut options: CompleterOptions,
74+
policy: CompletionSourcePolicy,
75+
native_results: F,
76+
ctx: &T,
77+
) -> Option<SuggestionResults>
78+
where
79+
T: CompletionContext,
80+
F: Future<Output = Option<Vec<ShellCompletion>>>,
81+
{
82+
let before_cursor_text = buffer_text.get(..cursor_position)?;
83+
options.fallback_strategy = policy.fallback_strategy(options.fallback_strategy);
84+
let warp_results = completer::suggestions(
85+
before_cursor_text,
86+
before_cursor_text.len(),
87+
session_env_vars,
88+
options,
89+
ctx,
90+
)
91+
.await;
92+
93+
resolve_completion_results(
94+
warp_results,
95+
native_results,
96+
before_cursor_text,
97+
policy.force_native_shell_completions,
98+
)
99+
.await
100+
}
101+
102+
async fn resolve_completion_results<F>(
103+
warp_results: Option<SuggestionResults>,
104+
native_results: F,
105+
before_cursor_text: &str,
106+
force_native_shell_completions: bool,
107+
) -> Option<SuggestionResults>
108+
where
109+
F: Future<Output = Option<Vec<ShellCompletion>>>,
110+
{
111+
if let Some(warp_results) = warp_results
112+
&& !warp_results.suggestions.is_empty()
113+
&& !force_native_shell_completions
114+
{
115+
return Some(warp_results);
116+
}
117+
118+
native_results.await.map(|results| {
119+
let token_end = before_cursor_text.len();
120+
let token_start = before_cursor_text
121+
.rfind(char::is_whitespace)
122+
.map(|position| position + 1)
123+
.unwrap_or_default();
124+
SuggestionResults {
125+
replacement_span: (token_start, token_end).into(),
126+
suggestions: results.into_iter().map(Into::into).collect(),
127+
match_strategy: MatchStrategy::Fuzzy,
128+
}
129+
})
130+
}
131+
132+
#[cfg(test)]
133+
#[path = "completion_source_tests.rs"]
134+
mod tests;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use std::future;
2+
3+
use warp_completer::completer::{
4+
CompletionsFallbackStrategy, Match, MatchStrategy, MatchedSuggestion, Priority, Suggestion,
5+
SuggestionResults, SuggestionType,
6+
};
7+
use warp_completer::meta::Span;
8+
use warpui::r#async::block_on;
9+
10+
use super::{CompletionSourcePolicy, resolve_completion_results};
11+
use crate::terminal::model::completions::ShellCompletion;
12+
13+
fn suggestion_results(name: &str) -> SuggestionResults {
14+
SuggestionResults {
15+
replacement_span: Span::new(0, 1),
16+
suggestions: vec![MatchedSuggestion::new(
17+
Suggestion::with_same_display_and_replacement(
18+
name,
19+
None,
20+
SuggestionType::Argument,
21+
Priority::default(),
22+
),
23+
Match::Prefix {
24+
is_case_sensitive: true,
25+
},
26+
)],
27+
match_strategy: MatchStrategy::Fuzzy,
28+
}
29+
}
30+
31+
#[test]
32+
fn policy_requires_enablement_shell_support_and_single_line_input() {
33+
let disabled = CompletionSourcePolicy::from_inputs(false, false, true, false);
34+
assert!(!disabled.should_request_native_shell_completions());
35+
assert!(matches!(
36+
disabled.fallback_strategy(CompletionsFallbackStrategy::FilePaths),
37+
CompletionsFallbackStrategy::FilePaths
38+
));
39+
40+
let forced = CompletionSourcePolicy::from_inputs(false, true, true, false);
41+
assert!(forced.should_request_native_shell_completions());
42+
assert!(matches!(
43+
forced.fallback_strategy(CompletionsFallbackStrategy::FilePaths),
44+
CompletionsFallbackStrategy::None
45+
));
46+
47+
let unsupported = CompletionSourcePolicy::from_inputs(true, false, false, false);
48+
assert!(!unsupported.should_request_native_shell_completions());
49+
50+
let multiline = CompletionSourcePolicy::from_inputs(true, false, true, true);
51+
assert!(!multiline.should_request_native_shell_completions());
52+
}
53+
54+
#[test]
55+
fn nonempty_warp_results_win_without_force() {
56+
let results = block_on(resolve_completion_results(
57+
Some(suggestion_results("warp")),
58+
future::ready(Some(vec![ShellCompletion::new("native".to_owned())])),
59+
"w",
60+
false,
61+
))
62+
.expect("Warp results should be retained");
63+
64+
assert_eq!(results.suggestions[0].replacement(), "warp");
65+
}
66+
67+
#[test]
68+
fn native_results_replace_empty_or_forced_warp_results() {
69+
let empty_warp_results = SuggestionResults {
70+
replacement_span: Span::new(0, 0),
71+
suggestions: vec![],
72+
match_strategy: MatchStrategy::Fuzzy,
73+
};
74+
let native_after_empty = block_on(resolve_completion_results(
75+
Some(empty_warp_results),
76+
future::ready(Some(vec![ShellCompletion::new("native".to_owned())])),
77+
"command na",
78+
false,
79+
))
80+
.expect("native results should replace empty Warp results");
81+
assert_eq!(native_after_empty.suggestions[0].replacement(), "native");
82+
assert_eq!(native_after_empty.replacement_span, Span::new(8, 10));
83+
84+
let native_after_force = block_on(resolve_completion_results(
85+
Some(suggestion_results("warp")),
86+
future::ready(Some(vec![ShellCompletion::new("native".to_owned())])),
87+
"λ native",
88+
true,
89+
))
90+
.expect("forced native results should replace Warp results");
91+
assert_eq!(native_after_force.suggestions[0].replacement(), "native");
92+
assert_eq!(native_after_force.replacement_span, Span::new(3, 9));
93+
}

app/src/completer/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod completion_source;
12
#[cfg(feature = "completions_v2")]
23
mod js;
34

@@ -8,6 +9,7 @@ use std::sync::Arc;
89

910
use anyhow::Result;
1011
use async_trait::async_trait;
12+
pub use completion_source::{CompletionSourcePolicy, completion_suggestions_with_native_fallback};
1113
use lazy_static::lazy_static;
1214
use smol_str::SmolStr;
1315
use typed_path::{TypedPath, TypedPathBuf};

app/src/input_suggestions.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use itertools::Itertools;
1010
use pathfinder_geometry::vector::vec2f;
1111
use warp_command_signatures::IconType;
1212
use warp_completer::completer::{
13-
MatchType, PathSeparators, Suggestion, SuggestionResults, SuggestionType,
13+
MatchType, PathSeparators, PreparedSuggestion, Suggestion, SuggestionResults, SuggestionType,
1414
};
1515
use warp_core::features::FeatureFlag;
1616
use warp_core::ui::theme::AnsiColorIdentifier;
@@ -273,9 +273,15 @@ fn filter_tab_suggestions(
273273
suggestions: &SuggestionResults,
274274
query: &str,
275275
path_separators: &[char],
276+
) -> Vec<Item> {
277+
items_from_prepared_suggestions(suggestions.prepare_for_query(query, path_separators))
278+
}
279+
280+
fn items_from_prepared_suggestions(
281+
suggestions: impl IntoIterator<Item = PreparedSuggestion>,
276282
) -> Vec<Item> {
277283
suggestions
278-
.filter_by_query(query, path_separators)
284+
.into_iter()
279285
.map(|suggestion| Item {
280286
// TODO(vorporeal): Consider changing the type of `text` and `display` here to be `SmolStr`.
281287
text: suggestion.suggestion.replacement.to_string(),
@@ -286,7 +292,7 @@ fn filter_tab_suggestions(
286292
.as_ref()
287293
.map(|desc| desc.clone().into()),
288294
matches: Some(suggestion.matching_indices),
289-
icon_type: Some(icon_type(suggestion.suggestion)),
295+
icon_type: Some(icon_type(&suggestion.suggestion)),
290296
match_type: suggestion.match_type,
291297
is_ai_query: false,
292298
is_history_item: false,
@@ -339,6 +345,27 @@ impl InputSuggestions {
339345
ctx.notify();
340346
}
341347

348+
pub fn set_prepared_tab_completions(
349+
&mut self,
350+
suggestions: Vec<PreparedSuggestion>,
351+
preselect_option: TabCompletionsPreselectOption,
352+
ctx: &mut ViewContext<Self>,
353+
) {
354+
self.set_items(items_from_prepared_suggestions(suggestions));
355+
if self.items.is_empty() {
356+
return;
357+
}
358+
359+
match preselect_option {
360+
TabCompletionsPreselectOption::First => self.select_first_item(ctx),
361+
TabCompletionsPreselectOption::Unselected => self.selected_index = None,
362+
TabCompletionsPreselectOption::Unchanged => {}
363+
}
364+
365+
self.cycle = true;
366+
ctx.notify();
367+
}
368+
342369
pub fn position_id_at_index(index: usize) -> String {
343370
format!("input_suggestions:index_{index}")
344371
}

0 commit comments

Comments
 (0)