Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Completion query transport and visible-menu ownership

## Status

Implemented; focused state tests and real Debian WSL native acceptance pass. The user's running installed build is unchanged until a replacement package is installed.

## Context

A WSL session could lose its completion list after typing a command prefix, while Tab and Enter were delayed or ineffective. The private editor query and background candidate refresh had separate defects.

## Evidence

The existing real WSL acceptance failed with an advertised Bash editor, no query reply and WIN32_INPUT_MODE. The console's ordinary VT function-key conversion does not preserve physical F24 as the reserved POSIX query sequence. Sending the agreed bytes directly restored the seven existing native scenarios.

An added c-prefix scenario still failed: the input remained c, the cursor anchor and popup mode were unchanged, but the list became empty while another calculation was active. A directory-cache generation update re-entered begin_completion_query, which cleared already usable results before the asynchronous replacement arrived.

## Decision

- POSIX editor queries use the advertised private byte sequence. PowerShell retains its native modifier chord. Do not change ordinary user-key encoding.
- The UI-owned pending calculation retains a query context: environment, directory, input, caret, presentation mode, syntax and editor revision. Allocate it only when a new calculation is required, not on cache-hit paints.
- A source-generation refresh for the same context keeps the visible candidates. A changed input or context still clears them immediately.
- Match the selected candidate by value when refreshed results arrive; an index alone can select a different command after reordering.
- Usable command candidates resolve the list action without waiting for a supplementary remote directory listing. Empty results still follow the existing pending-source behavior.
- Keep cancellation, exact query/environment checks and revision rejection for stale asynchronous results.

## Rejected alternatives

- Increasing the private-query timeout: it does not make an unsupported function-key translation deliver a reply and prolongs input interception.
- Disabling WSL completion or dropping native editor verification: removes the requested feature or risks editing a guessed caret position.
- Keeping every old popup: would preserve candidates from a different directory, connection or input.
- Adding polling threads or synchronous directory scans: unnecessary and contrary to the input-latency boundary.

## Consequences

One owned input context is retained for the pending/latest calculation. Existing results can remain visible during same-input source refresh, rather than flashing empty. No new watcher, background polling loop, process launch or cache lifetime is introduced.

## Validation

Seven focused editor state tests pass, including Win32-mode POSIX query bytes, unchanged PowerShell chord encoding, same-input directory generation refresh, and changed-input invalidation. Nine real WSL scenarios pass: popup/hybrid c-prefix menus remain available through 1.5 seconds of rendering and accept into the real Bash buffer; the prior three-mode immediate Tab, middle Unicode edit and Escape cases remain covered. Tests use an isolated desktop and shell-history file; no keys are injected into the user's existing pane.

## Supersedes

None.

## Revisit when

The shell query protocol gains an explicit out-of-band transport, or candidate ownership requires additional execution identity beyond the existing editor revision and environment.
7 changes: 5 additions & 2 deletions nebula_app/src/display/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,11 @@ impl NebulaPaneState {
self.completion_result_ready = true;
}

pub(crate) fn begin_completion_query(&mut self, key: String) {
self.clear_completion_hints();
pub(crate) fn begin_completion_query(&mut self, key: String, preserve_results: bool) {
if !preserve_results {
self.clear_completion_hints();
}
self.completion_result_ready = false;
self.suggestion_key = key;
}

Expand Down
28 changes: 25 additions & 3 deletions nebula_app/src/gpui_shell/terminal/suggest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,39 @@
#[cfg(test)]
pub(super) use crate::completion::history_hint_for_test;
pub(super) use crate::completion::{Cancellation, record_directory};
use crate::display::NebulaPaneState;
use crate::display::{CompletionStyle, NebulaPaneState, SuggestEnv};

/// 候选仍可接受的输入身份;数据源代际不是输入身份的一部分。
#[derive(PartialEq)]
pub(super) struct QueryContext {
pub cwd: String,
pub env: SuggestEnv,
pub line: String,
pub cursor: usize,
pub mode: CompletionStyle,
pub style: CompletionStyle,
pub syntax: Option<pebrel_completions::command_context::ShellSyntax>,
pub revision: u64,
}

/// 视图释放任务时同时通知已开始的同步计算,不能仅丢弃最后的 UI 回填。
pub(super) struct Pending {
_task: gpui::Task<()>,
cancellation: Cancellation,
context: std::sync::Arc<QueryContext>,
}

impl Pending {
pub(super) fn new(task: gpui::Task<()>, cancellation: Cancellation) -> Self {
Self { _task: task, cancellation }
pub(super) fn new(
task: gpui::Task<()>,
cancellation: Cancellation,
context: std::sync::Arc<QueryContext>,
) -> Self {
Self { _task: task, cancellation, context }
}

pub(super) fn matches_context(&self, context: &QueryContext) -> bool {
self.context.as_ref() == context
}

#[cfg(test)]
Expand Down
54 changes: 38 additions & 16 deletions nebula_app/src/gpui_shell/terminal/view/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,21 +295,32 @@ impl TerminalView {
}
return;
}
let context = std::sync::Arc::new(suggest::QueryContext {
cwd: self.suggest.cwd.clone(),
env: self.suggest.suggest_env.clone(),
line,
cursor,
mode,
style,
syntax: editor.as_ref().map(|snapshot| snapshot.syntax),
revision,
});
let preserve_results =
self.suggestion_task.as_ref().is_some_and(|pending| pending.matches_context(&context));
self.suggestion_task = None;
self.suggest.begin_completion_query(key.clone());
self.completion_viewport.update_query(&line, 0);
if self.suggest.completion_suppressed_line.as_deref() == Some(line.as_str()) {
// 目录回填只更新候选来源;同一输入的已显示结果不能在重算期间先被清空。
self.suggest.begin_completion_query(key.clone(), preserve_results);
self.completion_viewport.update_query(&context.line, self.suggest.completion_items.len());
if self.suggest.completion_suppressed_line.as_deref() == Some(context.line.as_str()) {
return;
}
self.suggest.completion_suppressed_line = None;
let cwd = self.suggest.cwd.clone();
let env = self.suggest.suggest_env.clone();
let cancellation = suggest::Cancellation::default();
let worker_cancellation = cancellation.clone();
let request = self.completion_session.request_with_syntax(
cwd.clone(),
env.clone(),
line,
context.cwd.clone(),
context.env.clone(),
context.line.clone(),
cursor,
style,
self.exec_context.as_ref(),
Expand All @@ -318,13 +329,14 @@ impl TerminalView {
// 本地目录也可能位于慢盘/网络挂载;扫描和历史首次加载都不能进入绘制回调。
let calculation =
cx.background_spawn(async move { request.calculate(&worker_cancellation) });
let expected_context = context.clone();
let task = cx.spawn(async move |this, cx| {
let result = calculation.await;
let _ = this.update(cx, |view, cx| {
// 按键、取消与 shell 切换都会使 key 或环境失效,旧结果不得回填。
if !view.suggest.completion_query_matches(&key)
|| view.suggest.cwd != cwd
|| view.suggest.suggest_env != env
|| view.suggest.cwd != expected_context.cwd
|| view.suggest.suggest_env != expected_context.env
|| view.completion_style != mode
|| mode.active_style(view.suggest.completion_popup_requested) != style
|| !view.ghost_enabled
Expand All @@ -347,25 +359,35 @@ impl TerminalView {
}
view.suggest.suggestion = result.suggestion;
view.suggest.suggestion_edit = result.suggestion_edit;
let selected = view
.suggest
.completion_selected
.and_then(|index| view.suggest.completion_items.get(index));
let next_selection = selected.and_then(|selected| {
result.completion_items.iter().position(|candidate| candidate == selected)
});
view.suggest.completion_items = result.completion_items;
if view.suggest.completion_popup_requested
&& !view.suggest.completion_items.is_empty()
{
view.suggest.completion_selected = Some(0);
}
view.suggest.completion_selected = next_selection.or_else(|| {
(view.suggest.completion_popup_requested
&& !view.suggest.completion_items.is_empty())
.then_some(0)
});
let awaiting_directory = result.pending_remote_dir.is_some();
view.suggest.pending_remote_dir = result.pending_remote_dir;
view.completion_viewport
.update_query(&view.suggest.screen_line, view.suggest.completion_items.len());
view.drive_pending_remote_dir(cx);
if !awaiting_directory {
view.suggest.finish_completion_query();
}
// 命令候选已经可用时即可接受,不让尚在补充的目录来源占着 Enter。
if !awaiting_directory || !view.suggest.completion_items.is_empty() {
view.finish_editor_action(cx);
}
cx.notify();
});
});
self.suggestion_task = Some(suggest::Pending::new(task, cancellation));
self.suggestion_task = Some(suggest::Pending::new(task, cancellation, context));
}

/// 补齐登记了一个还没缓存的来宾 / 远端目录时,去后台拉一次。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,52 @@ fn remote_editor_completion_native_shell_end_to_end() {
let run = async {
ready(cx, window.into(), &terminal).await?;
let mut reports = Vec::new();
for style in [CompletionStyle::Popup, CompletionStyle::Hybrid] {
ready(cx, window.into(), &terminal).await?;
cx.update_window(window.into(), |_, window, cx| terminal.update(cx, |view, cx| {
cx.global_mut::<Settings>().completion_style = style;
view.apply_settings(cx);
view.replace_text_in_range(None, "c", window, cx);
if style == CompletionStyle::Hybrid {
view.on_terminal_tab(&TerminalTab, window, cx);
}
})).map_err(|e| e.to_string())?;
wait_for(cx, window.into(), &terminal, |view| {
!view.completion_editor.is_querying() && !view.suggest.completion_items.is_empty()
}).await?;
// 超过旧的一秒查询超时,实际绘制期间菜单也必须保留,而非只检查回调结果。
for _ in 0..15 {
cx.background_executor().timer(Duration::from_millis(100)).await;
cx.update_window(window.into(), |_, window, cx| {
window.refresh();
window.draw(cx).clear(cx);
let view = terminal.read(cx);
if view.completion_popup_geometry().is_none() || view.completion_editor.is_querying() {
return Err(format!(
"c-prefix menu state: expected={style:?} actual={:?} line={:?} items={} selected={:?} anchor={:?} size={}x{} calculation={} editor={:?} mode={:?}",
view.completion_style,
view.suggest.screen_line, view.suggest.completion_items.len(),
view.suggest.completion_selected, view.suggest_anchor, view.cols, view.rows,
view.suggestion_task.is_some(),
view.completion_editor, view.term_mode(),
));
}
Ok::<_, String>(())
}).map_err(|e| e.to_string())??;
}
let expected = cx.update_window(window.into(), |_, window, cx| terminal.update(cx, |view, cx| {
if view.suggest.completion_selected.is_none() { key(view, "down", window, cx); }
let item = &view.suggest.completion_items[view.suggest.completion_selected.unwrap()];
let mut expected = "c".to_owned();
for _ in 0..item.replace_chars { expected.pop(); }
expected.push_str(&item.insert);
key(view, "enter", window, cx);
expected
})).map_err(|e| e.to_string())?;
probe(cx, window.into(), &terminal, &expected).await?;
reports.push(serde_json::json!({"route":fixture.route,"mode":format!("{style:?}"),"scenario":"c-prefix-stable-menu-enter","accepted":expected,"observation_ms":1500,"native_buffer_verified":true}));
cx.update_window(window.into(), |_, window, cx| terminal.update(cx, |view, cx| key(view, "ctrl-u", window, cx))).map_err(|e| e.to_string())?;
}
for (style, name) in [(CompletionStyle::Inline, "inline"), (CompletionStyle::Popup, "popup"), (CompletionStyle::Hybrid, "hybrid")] {
ready(cx, window.into(), &terminal).await?;
let branch = format!("qa/remote-{name}");
Expand Down
79 changes: 74 additions & 5 deletions nebula_app/src/gpui_shell/terminal/view/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,13 @@ impl TerminalView {
pub(super) fn completion_editor_query_bytes(&self) -> Vec<u8> {
let powershell =
self.completion_editor.owner.as_deref().is_some_and(|owner| owner.starts_with("pwsh:"));
let key =
gpui::Keystroke::parse(if powershell { "ctrl-shift-f12" } else { "f24" }).unwrap();
super::super::keymap::encode(&key, &self.term_mode()).unwrap_or_else(|| {
if powershell { b"\x1b[24;6~".to_vec() } else { b"\x1b[45~".to_vec() }
})
if !powershell {
// POSIX 绑定接收约定字节,不是物理 F24;ConPTY 的普通 VT 翻译不保留 F24。
return b"\x1b[45~".to_vec();
}
let key = gpui::Keystroke::parse("ctrl-shift-f12").unwrap();
super::super::keymap::encode(&key, &self.term_mode())
.unwrap_or_else(|| b"\x1b[24;6~".to_vec())
}

pub(super) fn query_completion_editor(&mut self, cx: &mut Context<Self>) -> bool {
Expand Down Expand Up @@ -295,6 +297,73 @@ impl TerminalView {
mod tests {
use super::*;

#[cfg(feature = "gpui-test-support")]
#[gpui::test]
fn directory_generation_refresh_keeps_the_visible_c_menu_and_selected_item(
cx: &mut gpui::TestAppContext,
) {
use crate::display::{CompletionStyle, SuggestEnv};
let (view, window, _) = super::startup_tests::open(cx);
let env = SuggestEnv::Wsl { distro: "stable-c-menu-fixture".into() };
crate::remote_dirs::finish_fetch(&env, "/project", Some(Vec::new()));
view.update(window, |view, cx| {
view.suggest.suggest_env = env.clone();
view.suggest.cwd = "/project".into();
view.completion_style = CompletionStyle::Popup;
view.ghost_enabled = true;
view.refresh_suggestion_from_snapshot(Some("c".into()), Some((0, 1)), cx);
});
window.run_until_parked();
let before = view.update(window, |view, _| {
assert!(!view.suggest.completion_items.is_empty());
view.suggest.completion_popup_move(1);
(view.suggest.completion_items.clone(), view.suggest.completion_selected)
});
crate::remote_dirs::finish_fetch(&env, "/another-directory", Some(Vec::new()));
view.update(window, |view, cx| {
view.refresh_suggestion_from_snapshot(Some("c".into()), Some((0, 1)), cx);
assert_eq!(
view.suggest.completion_items, before.0,
"revalidation must not blank the menu"
);
assert_eq!(view.suggest.completion_selected, before.1);
});
window.run_until_parked();
view.update(window, |view, cx| {
assert_eq!(view.suggest.completion_selected, before.1);
view.refresh_suggestion_from_snapshot(Some("other".into()), Some((0, 5)), cx);
assert!(
view.suggest.completion_items.is_empty(),
"a changed input must not keep old candidates"
);
});
}

#[cfg(feature = "gpui-test-support")]
#[gpui::test]
fn posix_editor_query_keeps_its_bytes_under_win32_input_mode(cx: &mut gpui::TestAppContext) {
let (view, window, _) = super::startup_tests::open(cx);
view.update(window, |view, _| {
let mut parser = nebula_terminal::vte::ansi::Processor::<
nebula_terminal::vte::ansi::StdSyncHandler,
>::default();
parser.advance(&mut *view.session.as_ref().unwrap().term.lock(), b"\x1b[?9001h");
assert!(view.term_mode().contains(TermMode::WIN32_INPUT_MODE));
view.completion_editor.advertise("wsl|Debian|bash:fixture");
assert_eq!(view.completion_editor_query_bytes(), b"\x1b[45~");
view.completion_editor.advertise("pwsh:fixture");
assert_eq!(
view.completion_editor_query_bytes(),
super::super::super::keymap::encode(
&gpui::Keystroke::parse("ctrl-shift-f12").unwrap(),
&view.term_mode(),
)
.unwrap(),
"PowerShell still receives the native chord rather than POSIX query text",
);
});
}

#[cfg(feature = "gpui-test-support")]
#[gpui::test]
fn directory_miss_keeps_manual_tab_until_the_remote_cache_is_ready(
Expand Down
Loading