diff --git a/src/terminal/composer.rs b/src/terminal/composer.rs index 441d2b64..8c069c78 100644 --- a/src/terminal/composer.rs +++ b/src/terminal/composer.rs @@ -130,8 +130,8 @@ pub(super) struct Step { /// - **Codex is always pasted.** It watches for bursts of fast keystrokes to /// spot pastes from terminals that do not bracket them, and the Enter after /// a typed burst is swallowed into it. -/// - **So is Gemini.** It judges each key of a burst against the input as it -/// was before the burst, so every `?` in a typed line reads as the key that +/// - **So is Gemini**, and Qwen Code with it ([`reads_like_gemini`]). It +/// judges each key of a burst against the input as it was before the burst, so every `?` in a typed line reads as the key that /// opens its shortcuts on an empty input and is lost, and every `!` as the /// one that switches it into shell mode — "hi! there" would run `there`. /// - **So are OpenCode and Amp.** OpenCode reads a typed burst the same way @@ -143,12 +143,14 @@ pub(super) struct Step { /// - **So is Copilot.** A typed leading `?` is the key that opens its help, /// and the rest of the line goes out without it. Its shell mode is a /// leading `!` it reads off a paste as well. -/// - **A leading `!` goes to Claude Code, Gemini and OpenCode on its own.** It +/// - **A leading `!` goes to Claude Code, Gemini, Qwen Code and OpenCode on +/// its own.** It /// switches the input into shell mode only when it is typed into an empty /// box as a key of its own; arriving with the rest of the line it is just a /// character. (Amp's shell mode is a leading `$`, which it reads off a paste /// as well.) -/// - **A message with an `@` mention gets a space after it for Gemini.** +/// - **A message with an `@` mention gets a space after it for Gemini and +/// Qwen Code.** /// With the caret at the end of an `@` word it lists matching files, and /// Enter then takes the list's pick instead of sending — so a message whose /// attachments close it would sit in the input, under the box, unsent. @@ -167,10 +169,8 @@ pub(super) fn submit_plan(agent: CLIAgent, text: &str, bracketed: bool) -> Vec Vec Vec tty7_core::core::paste::bracket(body.as_bytes()), false => body.as_bytes().to_vec(), @@ -210,7 +207,8 @@ pub(super) fn submit_plan(agent: CLIAgent, text: &str, bracketed: bool) -> Vec Duration::ZERO, - (false, CLIAgent::Copilot | CLIAgent::Gemini) if pasted => SETTLE_AFTER_PASTE_SLOW, + (false, CLIAgent::Copilot) if pasted => SETTLE_AFTER_PASTE_SLOW, + (false, agent) if pasted && reads_like_gemini(agent) => SETTLE_AFTER_PASTE_SLOW, (false, _) => SETTLE, }; steps.push(Step { @@ -224,7 +222,7 @@ pub(super) fn submit_plan(agent: CLIAgent, text: &str, bracketed: bool) -> Vec attached + _ if reads_like_gemini(agent) => attached .iter() .map(|p| mention(Some(agent), p, shell)) .collect::>() @@ -319,6 +317,13 @@ pub(super) struct InputArea { pub mode: Option<&'static str>, } +/// Whether `agent` reads its input the way Gemini CLI does. Qwen Code is a +/// fork of it and kept its input: the burst-typing traps, the shell-mode `!`, +/// the `@` list that takes Enter, and `@` mentions escaped the same way. +fn reads_like_gemini(agent: CLIAgent) -> bool { + matches!(agent, CLIAgent::Gemini | CLIAgent::Qwen) +} + /// Whether the box can be laid over this agent's input. fn covers(agent: CLIAgent) -> bool { matches!(agent, CLIAgent::Claude | CLIAgent::Codex | CLIAgent::Gemini) @@ -679,12 +684,14 @@ pub(super) fn command_items(commands: &[(String, String)], query: &str) -> Vec, path: &str, shell: Option<&str>) -> String { let spaced = path.chars().any(char::is_whitespace); match agent { - Some(CLIAgent::Gemini) => {} + Some(a) if reads_like_gemini(a) => {} Some(CLIAgent::Claude) if spaced => return format!("@\"{path}\""), Some(CLIAgent::Codex) if spaced && !path.contains('"') => return format!("\"{path}\""), Some(CLIAgent::Codex) => return path.to_string(), @@ -694,7 +701,9 @@ pub(super) fn mention(agent: Option, path: &str, shell: Option<&str>) Quoting::Posix => { let mut out = String::with_capacity(path.len()); for c in path.chars() { - if " \t()[]{};|*?$`'\"#&<>!~\\".contains(c) { + if " \t()[]{};|*?$`'\"#&<>!~\\".contains(c) + || (c == ',' && agent == Some(CLIAgent::Qwen)) + { out.push('\\'); } out.push(c); @@ -2468,6 +2477,33 @@ mod tests { ); } + #[test] + fn qwen_is_spoken_to_the_way_gemini_is() { + // Typed, its leading `?` opens the shortcuts and is lost. + let steps = submit_plan(CLIAgent::Qwen, "?why", true); + assert_eq!(bytes(&steps), [&b"\x1b[200~?why\x1b[201~"[..], b"\r"]); + assert_eq!(steps[1].delay, SETTLE_AFTER_PASTE_SLOW); + let steps = submit_plan(CLIAgent::Qwen, "!ls", true); + assert_eq!(bytes(&steps), [&b"!"[..], b"\x1b[200~ls\x1b[201~", b"\r"]); + // Its `@` list takes an Enter that comes right after a mention. + let steps = submit_plan(CLIAgent::Qwen, "read @a.rs", false); + assert_eq!(bytes(&steps), [&b"read @a.rs "[..], b"\r"]); + // Mentions as Gemini spells them, with the comma Qwen ends one at. + assert_eq!( + mention(Some(CLIAgent::Qwen), "my notes, v2.md", Some("zsh")), + r"@my\ notes\,\ v2.md" + ); + assert_eq!( + mention(Some(CLIAgent::Gemini), "a,b.md", Some("zsh")), + "@a,b.md" + ); + let attached = vec!["/tmp/a b.png".to_string()]; + assert_eq!( + compose_message(CLIAgent::Qwen, "look", &attached, Some("zsh")), + r"look @/tmp/a\ b.png" + ); + } + #[test] fn a_mention_with_a_space_is_spelled_the_way_each_agent_reads_it() { let zsh = Some("zsh");