1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
//! Server-suggested values for the MCP prompt argument being typed.
//!
//! `completion/complete` is a round-trip, and the command palette matches
//! synchronously on every keystroke, so the two cannot meet directly. They meet
//! through a cache instead: matching only ever reads what has already arrived,
//! while the event loop starts and collects the requests that fill it.
//!
//! The request policy is deliberately clock-free. At most one request is in
//! flight at a time, and the next one is chosen from wherever the cursor ended
//! up once that request lands. Holding a key down therefore costs one round-trip
//! per round-trip rather than one per character, without a debounce timer to
//! tune or a wall-clock delay for tests to wait out. Every finished request is
//! recorded, including a failed one, so a server that cannot answer is asked
//! once per value rather than on every pass of the loop.
use std::{collections::VecDeque, ops::Range};
use super::{App, CommandChoice, CommandChoiceKind, ComposerMode};
use crate::{
commands,
tools::mcp::{
catalog::{McpPrompt, McpPromptArgument},
McpCompletionSupport,
},
};
/// How many finished lookups stay available for reuse.
///
/// Every keystroke inside a value makes a new key, so the cache pays off on
/// backspace, on re-reading a value, and on the repeated passes the event loop
/// makes while the composer sits still. A few dozen entries cover that and stop
/// a long session from growing without bound.
const COMPLETION_CACHE_LIMIT: usize = 64;
/// What one `completion/complete` request is for.
///
/// Equality is the cache key: the same four values always describe the same
/// question, so a repeat is answered locally instead of asked again.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct McpCompletionKey {
pub(in crate::tui) server: String,
pub(in crate::tui) prompt: String,
pub(in crate::tui) argument: String,
pub(in crate::tui) typed: String,
}
/// The argument value the cursor sits in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct McpArgumentCursor {
pub(in crate::tui) key: McpCompletionKey,
/// Char range of the value within the composer text. Only this range is
/// rewritten when a suggestion is picked.
pub(in crate::tui) value: Range<usize>,
}
impl McpArgumentCursor {
/// What the palette is currently answering, so a move to a different value
/// starts the selection over instead of leaving it on an unrelated row.
pub(super) fn palette_identity(&self) -> String {
let McpCompletionKey {
server,
prompt,
argument,
typed,
} = &self.key;
format!("mcp:{server}:{prompt} {argument}={typed}")
}
}
/// What the event loop should do about the value under the cursor.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum McpCompletionStep {
/// Send nothing: the cursor is not in a value, the answer is already
/// cached, or a request is already in flight.
Wait,
/// Send exactly this request.
Ask(McpCompletionKey),
}
/// Suggestions already fetched, plus the single request in flight.
#[derive(Debug, Default)]
pub(super) struct McpArgumentCompletions {
/// Insertion-ordered, oldest first, so the cap evicts the oldest entry.
cache: VecDeque<(McpCompletionKey, Vec<String>)>,
pending: Option<PendingCompletion>,
}
#[derive(Debug)]
struct PendingCompletion {
key: McpCompletionKey,
handle: tokio::task::JoinHandle<Vec<String>>,
}
impl McpArgumentCompletions {
pub(super) fn suggestions(&self, key: &McpCompletionKey) -> Option<&[String]> {
self.cache
.iter()
.find(|(cached, _)| cached == key)
.map(|(_, values)| values.as_slice())
}
pub(super) fn is_pending(&self) -> bool {
self.pending.is_some()
}
/// Decide what to send for the value under the cursor.
pub(super) fn next_step(&self, wanted: Option<&McpCompletionKey>) -> McpCompletionStep {
let Some(key) = wanted else {
return McpCompletionStep::Wait;
};
if self.pending.is_some() || self.suggestions(key).is_some() {
return McpCompletionStep::Wait;
}
McpCompletionStep::Ask(key.clone())
}
pub(super) fn store(&mut self, key: McpCompletionKey, values: Vec<String>) {
if self.cache.len() >= COMPLETION_CACHE_LIMIT {
self.cache.pop_front();
}
self.cache.push_back((key, values));
}
/// Drop a request nobody will read, on shutdown.
pub(super) fn cancel(&mut self) {
if let Some(pending) = self.pending.take() {
pending.handle.abort();
}
}
}
impl App {
/// Palette rows for the value under the cursor.
///
/// Reads the cache and nothing else, so per-keystroke matching stays local.
pub(super) fn mcp_argument_choices(&self) -> Vec<CommandChoice> {
let Some(cursor) = self.mcp_argument_cursor() else {
return Vec::new();
};
argument_choices(&cursor, &self.mcp_argument_completions)
}
/// The request the composer currently calls for, if any.
pub(super) fn mcp_argument_cursor(&self) -> Option<McpArgumentCursor> {
// Nothing is asked for a value no palette would show it for: a picker
// or a shell line is holding the composer, or the palette was dismissed
// for this keystroke. The checks are the cheapest available, and they
// run before the catalog is touched.
if !matches!(self.input_ui.composer(), ComposerMode::Input)
|| self.input_ui.shell_mode().is_some()
|| self.input_ui.command_palette_dismissed()
{
return None;
}
let text = self.input_ui.text();
let (server, name) = super::mcp_prompt::parse_command(commands::command_prefix(text)?)?;
let prompt = self
.mcp_catalog
.prompts()
.into_iter()
.find(|prompt| prompt.server == server && prompt.name == name)?;
argument_under_cursor(
text,
self.input_ui.cursor(),
&prompt,
self.mcp_catalog.completion_support(&server),
)
}
/// Collect a finished lookup and start the next one. Returns whether the
/// palette must redraw.
pub(super) async fn poll_mcp_argument_completion(&mut self) -> bool {
let mut redraw = false;
if let Some(pending) = self
.mcp_argument_completions
.pending
.take_if(|pending| pending.handle.is_finished())
{
// A cancelled or panicking task counts as an empty answer. The key
// is recorded either way, so a server that cannot answer is not
// asked again on the next pass of the loop.
let values = pending.handle.await.unwrap_or_default();
self.mcp_argument_completions.store(pending.key, values);
redraw = true;
}
let wanted = self.mcp_argument_cursor().map(|cursor| cursor.key);
if let McpCompletionStep::Ask(key) =
self.mcp_argument_completions.next_step(wanted.as_ref())
{
let catalog = self.mcp_catalog.clone();
let request = key.clone();
self.mcp_argument_completions.pending = Some(PendingCompletion {
key,
handle: tokio::spawn(async move {
catalog
.complete_prompt_argument(
&request.server,
&request.prompt,
&request.argument,
&request.typed,
)
.await
}),
});
}
redraw
}
}
/// Turn whatever has arrived for this value into palette rows.
///
/// Nothing cached and a request that came back empty are the same row set,
/// because a suggestion is help: when the server has none to give, or could not
/// answer at all, the palette says nothing rather than reporting a failure at
/// someone mid-sentence.
pub(super) fn argument_choices(
cursor: &McpArgumentCursor,
completions: &McpArgumentCompletions,
) -> Vec<CommandChoice> {
completions
.suggestions(&cursor.key)
.unwrap_or_default()
.iter()
.map(|value| CommandChoice {
usage: value.clone(),
description: format!(
"{} · suggested by MCP server `{}`",
cursor.key.argument, cursor.key.server
),
name: value.clone(),
kind: CommandChoiceKind::McpPromptArgument {
value: cursor.value.clone(),
},
})
.collect()
}
/// Which argument value the cursor sits in, for a prompt typed as a command.
///
/// Kept separate from the catalog lookup so the rule can be checked against
/// text and a cursor alone.
pub(super) fn argument_under_cursor(
text: &str,
cursor: usize,
prompt: &McpPrompt,
support: McpCompletionSupport,
) -> Option<McpArgumentCursor> {
match support {
// Asking a server that never declared `completions` can only earn an
// error, so the cursor is treated as sitting in no value at all.
McpCompletionSupport::Absent => return None,
McpCompletionSupport::Declared => {}
}
let chars = text.chars().collect::<Vec<_>>();
if chars.first() != Some(&'/') {
return None;
}
let token_end = chars.iter().position(|ch| ch.is_whitespace())?;
// Inside the command token the palette is still completing the command
// itself, and those matches must not be displaced.
if cursor <= token_end {
return None;
}
let (argument, value) = typed_value(&chars, token_end, cursor, &prompt.arguments)?;
Some(McpArgumentCursor {
key: McpCompletionKey {
server: prompt.server.clone(),
prompt: prompt.name.clone(),
argument,
typed: chars[value.clone()].iter().collect(),
},
value,
})
}
/// Rewrite one char range of the composer, leaving the rest of the line alone.
pub(super) fn replace_value(text: &str, value: &Range<usize>, chosen: &str) -> (String, usize) {
let chars = text.chars().collect::<Vec<_>>();
let start = value.start.min(chars.len());
let end = value.end.clamp(start, chars.len());
let head = chars[..start].iter().collect::<String>();
let tail = chars[end..].iter().collect::<String>();
(
format!("{head}{chosen}{tail}"),
start + chosen.chars().count(),
)
}
/// The declared argument the cursor is filling in, and the char range of its
/// value within `chars`.
fn typed_value(
chars: &[char],
token_end: usize,
cursor: usize,
arguments: &[McpPromptArgument],
) -> Option<(String, Range<usize>)> {
// A prompt with exactly one argument takes the whole trailing text as that
// argument's value, matching how `McpPrompt::parse_arguments` reads it back.
if let [only] = arguments {
let start = (token_end..chars.len())
.find(|index| !chars[*index].is_whitespace())
.unwrap_or(chars.len());
let end = chars[start..]
.iter()
.rposition(|ch| !ch.is_whitespace())
.map_or(start, |offset| start + offset + 1);
return Some((only.name.clone(), start..end));
}
let word = word_at(chars, token_end, cursor)?;
let equals = chars[word.clone()]
.iter()
.position(|ch| *ch == '=')
.map(|offset| word.start + offset)?;
// Before the `=` the user is still naming the argument, and a name is not
// something the server offers values for.
if cursor <= equals {
return None;
}
let argument = chars[word.start..equals].iter().collect::<String>();
// An argument the server never declared cannot be completed, and asking
// about it would only earn an error.
if !arguments.iter().any(|declared| declared.name == argument) {
return None;
}
Some((argument, equals + 1..word.end))
}
/// The whitespace-delimited word the cursor sits in or has just finished,
/// searched only in the text after the command token.
fn word_at(chars: &[char], token_end: usize, cursor: usize) -> Option<Range<usize>> {
let mut index = token_end;
while index < chars.len() {
if chars[index].is_whitespace() {
index += 1;
continue;
}
let start = index;
while index < chars.len() && !chars[index].is_whitespace() {
index += 1;
}
if (start..=index).contains(&cursor) {
return Some(start..index);
}
}
None
}
#[cfg(test)]
#[path = "mcp_argument_completion_tests.rs"]
mod tests;