feat(repl): add .prompt command with live staged tab-completion
Implements plans/mcp-resources-prompts-design.md §5.1/§5.4 (T6): - .prompt <server> <name> [key=value ...] fetches an MCP prompt and submits the result as chat input via Input::from_str + ask(), never through REPL line parsing; GetPromptResult messages are flattened into one user-role block with unconditional [user]/[assistant] labels - missing required prompt arguments are collected interactively - .list prompts renders server/name/description/args via the unified catalog (CatalogItem gains an arguments field), degrading per server - staged live tab-completion: enabled+running+prompts-capable servers (no RPC), then live prompt names, then key= argument suggestions with (required) markers; 2s timeout per RPC, all errors degrade to silent empty suggestions, ctx read guard dropped before blocking - the enabled-server alias expansion is factored into a shared helper used by both tool-scope rebuild and completion - BREAKING: the former .prompt <text> temp-role builtin is renamed to .temp-role <text> (behavior preserved); .prompt now belongs to MCP prompts, and a user macro named prompt or temp-role is shadowed
This commit is contained in:
+240
-1
@@ -1,11 +1,17 @@
|
||||
use super::{REPL_COMMANDS, ReplCommand};
|
||||
|
||||
use crate::{config::RequestContext, utils::fuzzy_filter};
|
||||
use crate::config::{McpPromptCompletion, RequestContext};
|
||||
use crate::mcp::ConnectedServer;
|
||||
use crate::utils::fuzzy_filter;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use reedline::{Completer, Span, Suggestion};
|
||||
use rmcp::model::Prompt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const PROMPT_COMPLETION_RPC_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
impl Completer for ReplCompleter {
|
||||
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
|
||||
@@ -29,6 +35,22 @@ impl Completer for ReplCompleter {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
if cmd == ".prompt" && parts_len > 1 {
|
||||
let span = Span::new(parts[parts_len - 1].1, pos);
|
||||
let args: Vec<&str> = parts.iter().skip(1).map(|(v, _)| *v).collect();
|
||||
let filter = args.last().copied().unwrap_or_default().to_string();
|
||||
let stage = {
|
||||
let ctx = self.ctx.read();
|
||||
ctx.mcp_prompt_completion(&args)
|
||||
};
|
||||
return complete_prompt_stage(stage, &filter, PROMPT_COMPLETION_RPC_TIMEOUT)
|
||||
.iter()
|
||||
.map(|(value, description)| {
|
||||
create_suggestion(value, description.as_deref().unwrap_or_default(), span)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let ctx = self.ctx.read();
|
||||
let state = ctx.state();
|
||||
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
|
||||
@@ -141,6 +163,57 @@ fn create_suggestion(value: &str, description: &str, span: Span) -> Suggestion {
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_prompt_stage(
|
||||
stage: McpPromptCompletion,
|
||||
filter: &str,
|
||||
rpc_timeout: Duration,
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
let values = match stage {
|
||||
McpPromptCompletion::Ready(values) => values,
|
||||
McpPromptCompletion::PromptNames { server } => list_prompts_blocking(server, rpc_timeout)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|prompt| (prompt.name, prompt.description))
|
||||
.collect(),
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt,
|
||||
typed_keys,
|
||||
} => list_prompts_blocking(server, rpc_timeout)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.name == prompt)
|
||||
.and_then(|candidate| candidate.arguments)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|arg| !typed_keys.contains(&arg.name))
|
||||
.map(|arg| {
|
||||
let description = match (arg.required == Some(true), arg.description) {
|
||||
(true, Some(description)) => Some(format!("{description} (required)")),
|
||||
(true, None) => Some("(required)".to_string()),
|
||||
(false, description) => description,
|
||||
};
|
||||
(format!("{}=", arg.name), description)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
fuzzy_filter(values, |(value, _)| value.as_str(), filter)
|
||||
}
|
||||
|
||||
fn list_prompts_blocking(
|
||||
server: Arc<ConnectedServer>,
|
||||
rpc_timeout: Duration,
|
||||
) -> Option<Vec<Prompt>> {
|
||||
let fut = async move { tokio::time::timeout(rpc_timeout, server.list_all_prompts()).await };
|
||||
// block_in_place is only sound because the REPL's read_line runs inside the
|
||||
// main-thread block_on of the multi-thread runtime.
|
||||
let result = match tokio::runtime::Handle::try_current().ok() {
|
||||
Some(handle) => tokio::task::block_in_place(|| handle.block_on(fut)),
|
||||
None => tokio::runtime::Runtime::new().ok()?.block_on(fut),
|
||||
};
|
||||
result.ok()?.ok()
|
||||
}
|
||||
|
||||
fn split_line(line: &str) -> Vec<(&str, usize)> {
|
||||
let mut parts = vec![];
|
||||
let mut part_start = None;
|
||||
@@ -178,3 +251,169 @@ fn test_split_line() {
|
||||
vec![(".set", 0), ("highlight", 5), ("t", 15)],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prompt_completion_tests {
|
||||
use super::*;
|
||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn prompts_fixture() -> FixtureServer {
|
||||
FixtureServer {
|
||||
prompts_capability: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_two_lists_prompt_names_with_descriptions() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![(
|
||||
"summarize".to_string(),
|
||||
Some("Summarize a document".to_string())
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_three_suggests_argument_keys_with_required_marker() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "summarize".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
(
|
||||
"path=".to_string(),
|
||||
Some("Document path (required)".to_string())
|
||||
),
|
||||
("style=".to_string(), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_three_excludes_typed_keys_and_fuzzy_filters() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server: Arc::clone(&server),
|
||||
prompt: "summarize".to_string(),
|
||||
typed_keys: vec!["path".to_string()],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(values, vec![("style=".to_string(), None)]);
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "summarize".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"sty",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(values, vec![("style=".to_string(), None)]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_three_unknown_prompt_is_empty() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "ghost".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(values.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn slow_listing_times_out_to_empty() {
|
||||
let fixture = FixtureServer {
|
||||
prompt_delay: Some(Duration::from_millis(200)),
|
||||
..prompts_fixture()
|
||||
};
|
||||
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
|
||||
assert!(values.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_listing_is_swallowed_without_retry() {
|
||||
let fixture = FixtureServer {
|
||||
fail_prompt_listings: true,
|
||||
..prompts_fixture()
|
||||
};
|
||||
let list_prompts_calls = Arc::clone(&fixture.list_prompts_calls);
|
||||
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(values.is_empty());
|
||||
assert_eq!(list_prompts_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_without_ambient_runtime_uses_fallback_runtime() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let (runtime, _server) = rt.block_on(fixture_runtime(prompts_fixture()));
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![(
|
||||
"summarize".to_string(),
|
||||
Some("Summarize a document".to_string())
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+146
-11
@@ -13,7 +13,7 @@ use crate::client::{
|
||||
};
|
||||
use crate::config::{
|
||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
||||
StateFlags, macro_execute,
|
||||
StateFlags, flatten_prompt_messages, macro_execute,
|
||||
};
|
||||
use crate::config::{AssetCategory, paths};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
@@ -29,6 +29,7 @@ use anyhow::{Context, Result, bail};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use fancy_regex::Regex;
|
||||
use indoc::indoc;
|
||||
use inquire::Text;
|
||||
use log::warn;
|
||||
use parking_lot::RwLock;
|
||||
use reedline::CursorConfig;
|
||||
@@ -38,6 +39,8 @@ use reedline::{
|
||||
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
|
||||
};
|
||||
use reedline::{MenuBuilder, Signal};
|
||||
use rmcp::model::PromptArgument;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::{env, process, sync::Arc};
|
||||
use tokio::task;
|
||||
@@ -53,7 +56,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 62]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -105,6 +108,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
ReplCommand::new(".model", "Switch LLM model", AssertState::pass()),
|
||||
ReplCommand::new(
|
||||
".prompt",
|
||||
"Invoke an MCP prompt and submit the result as chat input",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".temp-role",
|
||||
"Set a temporary role using a prompt",
|
||||
AssertState::False(StateFlags::SESSION | StateFlags::AGENT),
|
||||
),
|
||||
@@ -307,7 +315,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
),
|
||||
ReplCommand::new(
|
||||
".list",
|
||||
"List roles, sessions, agents, RAGs, macros, skills, tools, MCP servers, or bundles",
|
||||
"List roles, sessions, agents, RAGs, macros, skills, prompts, tools, MCP servers, or bundles",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
@@ -774,12 +782,46 @@ pub async fn run_repl_command(
|
||||
.tool disable <name> # Disable a single tool in the current context"#
|
||||
),
|
||||
},
|
||||
".prompt" => match args {
|
||||
".prompt" => {
|
||||
let (words, _) = split_args_text(args.unwrap_or_default(), cfg!(windows));
|
||||
match words.as_slice() {
|
||||
[server, name, rest @ ..] => {
|
||||
let provided = parse_prompt_call_args(rest)?;
|
||||
let prompts = ctx.tool_scope.mcp_runtime.list_prompts(server).await?;
|
||||
let declared = prompts
|
||||
.into_iter()
|
||||
.find(|prompt| prompt.name == *name)
|
||||
.with_context(|| {
|
||||
format!("Prompt '{name}' not found on MCP server '{server}'")
|
||||
})?
|
||||
.arguments
|
||||
.unwrap_or_default();
|
||||
let (mut arguments, missing) = resolve_prompt_args(&declared, provided);
|
||||
for key in missing {
|
||||
let value =
|
||||
Text::new(&format!("{key}:")).prompt().with_context(|| {
|
||||
format!("Failed to read prompt argument '{key}'")
|
||||
})?;
|
||||
arguments.insert(key, value);
|
||||
}
|
||||
let result = ctx
|
||||
.tool_scope
|
||||
.mcp_runtime
|
||||
.prompt(server, name, arguments)
|
||||
.await?;
|
||||
let flattened = flatten_prompt_messages(&result.messages);
|
||||
let input = Input::from_str(ctx, &flattened, None)?;
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
_ => println!("Usage: .prompt <server> <name> [key=value ...]"),
|
||||
}
|
||||
}
|
||||
".temp-role" => match args {
|
||||
Some(text) => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.use_prompt(app.as_ref(), text)?;
|
||||
}
|
||||
None => println!("Usage: .prompt <text>..."),
|
||||
None => println!("Usage: .temp-role <text>..."),
|
||||
},
|
||||
".role" => match args {
|
||||
Some(args) => match args.split_once(['\n', ' ']) {
|
||||
@@ -1207,13 +1249,16 @@ pub async fn run_repl_command(
|
||||
println!("Usage: .uninstall <bundle-name> [--yes] (see `.uninstall --help`)")
|
||||
}
|
||||
},
|
||||
".list" => match args {
|
||||
".list" => match args.map(str::trim) {
|
||||
Some("prompts") => {
|
||||
ctx.list_prompt_assets().await?;
|
||||
}
|
||||
Some(args) => {
|
||||
ctx.list_assets(args.trim())?;
|
||||
ctx.list_assets(args)?;
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers|bundles>"
|
||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|prompts|tools|mcp-servers|bundles>"
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -1737,6 +1782,40 @@ fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_prompt_call_args(words: &[String]) -> Result<HashMap<String, String>> {
|
||||
let mut args = HashMap::new();
|
||||
for word in words {
|
||||
let Some((key, value)) = word.split_once('=') else {
|
||||
bail!("Invalid prompt argument '{word}': arguments must be key=value pairs");
|
||||
};
|
||||
args.insert(key.to_string(), unquote_prompt_value(value).to_string());
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn unquote_prompt_value(value: &str) -> &str {
|
||||
let quoted = value.len() >= 2
|
||||
&& ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')));
|
||||
if quoted {
|
||||
&value[1..value.len() - 1]
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_prompt_args(
|
||||
declared: &[PromptArgument],
|
||||
provided: HashMap<String, String>,
|
||||
) -> (HashMap<String, String>, Vec<String>) {
|
||||
let missing = declared
|
||||
.iter()
|
||||
.filter(|arg| arg.required == Some(true) && !provided.contains_key(&arg.name))
|
||||
.map(|arg| arg.name.clone())
|
||||
.collect();
|
||||
(provided, missing)
|
||||
}
|
||||
|
||||
pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
@@ -1888,8 +1967,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_61_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 61);
|
||||
fn repl_commands_has_62_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_call_args_splits_on_first_equals_and_unquotes() {
|
||||
let words = vec![
|
||||
"path=notes.txt".to_string(),
|
||||
r#"style="a b""#.to_string(),
|
||||
"expr=a=b".to_string(),
|
||||
];
|
||||
|
||||
let args = parse_prompt_call_args(&words).unwrap();
|
||||
|
||||
assert_eq!(args["path"], "notes.txt");
|
||||
assert_eq!(args["style"], "a b");
|
||||
assert_eq!(args["expr"], "a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_call_args_rejects_words_without_equals() {
|
||||
let err = parse_prompt_call_args(&["positional".to_string()])
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
"Invalid prompt argument 'positional': arguments must be key=value pairs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prompt_args_reports_missing_required_only() {
|
||||
let declared = vec![
|
||||
PromptArgument::new("path").with_required(true),
|
||||
PromptArgument::new("style"),
|
||||
];
|
||||
|
||||
let (resolved, missing) = resolve_prompt_args(&declared, HashMap::new());
|
||||
assert!(resolved.is_empty());
|
||||
assert_eq!(missing, vec!["path".to_string()]);
|
||||
|
||||
let provided = HashMap::from([("path".to_string(), "notes.txt".to_string())]);
|
||||
let (resolved, missing) = resolve_prompt_args(&declared, provided);
|
||||
assert_eq!(resolved["path"], "notes.txt");
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2105,10 +2228,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_prompt_blocked_in_session_or_agent() {
|
||||
fn repl_commands_prompt_always_available() {
|
||||
let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".prompt").unwrap();
|
||||
assert!(cmd.is_valid(StateFlags::empty()));
|
||||
assert!(cmd.is_valid(StateFlags::ROLE));
|
||||
assert!(cmd.is_valid(StateFlags::SESSION));
|
||||
assert!(cmd.is_valid(StateFlags::AGENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_temp_role_blocked_in_session_or_agent() {
|
||||
let cmd = REPL_COMMANDS
|
||||
.iter()
|
||||
.find(|c| c.name == ".temp-role")
|
||||
.unwrap();
|
||||
assert!(cmd.is_valid(StateFlags::empty()));
|
||||
assert!(cmd.is_valid(StateFlags::ROLE));
|
||||
assert!(!cmd.is_valid(StateFlags::SESSION));
|
||||
assert!(!cmd.is_valid(StateFlags::AGENT));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user