From 16626e05be4e67a429fa2edf5387b5d2bf584c7f Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 6 Oct 2026 12:36:49 +0800 Subject: [PATCH] fix(composer): send commands and skills the way the harness expands them The `/` menu now opens only at the start of the message and never for a path, and a `/` or `$` token that matches nothing opens no menu, so Enter submits it instead of being swallowed. A leading `$skill` is sent as `/skill` when the provider also lists it as a command (Claude), the only form Claude Code expands; Codex mentions stay `$skill`. The Ultrathink keyword trails a slash command instead of displacing it from byte 0. Codex `~/.codex/prompts` entries are no longer offered: the app-server neither lists nor expands them. --- crates/agent/src/claude.rs | 15 ++- crates/agent/src/codex.rs | 115 +----------------- crates/agent/src/lib.rs | 7 +- crates/runtime/src/app/active_session.rs | 55 +++++++-- crates/runtime/src/app/mod.rs | 6 +- crates/runtime/src/app/options.rs | 2 +- crates/runtime/src/app/send.rs | 4 +- crates/runtime/src/app/tests.rs | 43 +++++++ .../src/composer/components/trigger_menu.rs | 53 ++++++-- crates/ui/src/composer/mod.rs | 8 +- crates/ui/src/composer_trigger.rs | 28 +++-- locales/en.yml | 2 - locales/zh-CN.yml | 2 - 13 files changed, 187 insertions(+), 153 deletions(-) diff --git a/crates/agent/src/claude.rs b/crates/agent/src/claude.rs index c85fb45d..68c982bb 100644 --- a/crates/agent/src/claude.rs +++ b/crates/agent/src/claude.rs @@ -1018,11 +1018,16 @@ async fn resolve_response( } } +/// Add the `ultrathink` keyword, which Claude Code honours anywhere in the +/// prompt. A message that starts with `/` is a slash command, and the CLI +/// expands one only at byte 0, so there the keyword trails the text instead. fn turn_text(text: String, ultrathink: bool) -> String { - if ultrathink { - format!("Ultrathink:\n{text}") - } else { + if !ultrathink { text + } else if text.starts_with('/') { + format!("{text}\n\nultrathink") + } else { + format!("Ultrathink:\n{text}") } } @@ -3278,7 +3283,9 @@ fn map_usage(usage: &Value, model_usage: Option<&Value>) -> TokenUsage { /// Parse Claude system-init `slash_commands` (→ [`ProviderCommandKind::Command`]) /// and `skills` (→ [`ProviderCommandKind::Skill`]) into [`ProviderCommand`]s. -/// Both are arrays of bare name strings; the CLI supplies no descriptions. +/// Both are arrays of bare name strings; the CLI supplies no descriptions. A +/// skill is listed under both, which is how the runtime knows a `$name` +/// mention of it is sent as the `/name` command the CLI expands. fn parse_provider_commands(init: &Value) -> Vec { let mut out = Vec::new(); let mut push = |field: &str, kind: ProviderCommandKind| { diff --git a/crates/agent/src/codex.rs b/crates/agent/src/codex.rs index 33f5bb31..d9ab0393 100644 --- a/crates/agent/src/codex.rs +++ b/crates/agent/src/codex.rs @@ -924,11 +924,12 @@ async fn initialize_and_open_thread( .or_else(|| opts.model.clone()); // Discover the session's skills for the composer's `$` menu. Supported since - // codex 0.144.1 (verified live). That protocol version has no request for - // custom prompts/commands, so those come from CODEX_HOME/prompts/*.md below. - // Either source failing is non-fatal so older builds still start. + // codex 0.144.1 (verified live); a failure is non-fatal so older builds + // still start. The app-server expands a `$name` mention in plain turn text + // itself; it has no request to list or run `~/.codex/prompts` files, so + // those are not offered. let mut next_id = 3; - let mut provider_commands = match request_codex_skills(&opts.cwd, stdin, lines, next_id).await { + let provider_commands = match request_codex_skills(&opts.cwd, stdin, lines, next_id).await { Ok(commands) => commands, Err(err) => { log::debug!("codex skills/list unavailable: {err}"); @@ -936,7 +937,6 @@ async fn initialize_and_open_thread( } }; next_id += 1; - provider_commands.extend(load_codex_prompts(&opts.launch_env)); Ok((thread_id, model, next_id, provider_commands)) } @@ -1018,68 +1018,6 @@ fn parse_codex_skills(result: &Value) -> Vec { out } -/// Resolve the Codex data home exactly as the spawned provider sees it: the -/// dedicated home override wins, then the inherited process variables, finally -/// `$HOME/.codex`. -fn codex_home(launch_env: &LaunchEnv) -> Option { - launch_env - .home - .clone() - .or_else(|| std::env::var_os("CODEX_HOME").map(PathBuf::from)) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) -} - -/// Map custom prompt files into slash commands. The app-server schema in Codex -/// 0.144.1 exposes no custom-prompt listing request, so the CLI's documented -/// on-disk prompt directory is the compatibility source. -fn load_codex_prompts(launch_env: &LaunchEnv) -> Vec { - let Some(home) = codex_home(launch_env) else { - return Vec::new(); - }; - let prompts_dir = home.join("prompts"); - let Ok(entries) = std::fs::read_dir(&prompts_dir) else { - return Vec::new(); - }; - let mut paths: Vec = entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| { - path.is_file() - && path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) - }) - .collect(); - paths.sort(); - - paths - .into_iter() - .filter_map(|path| { - let name = path.file_stem()?.to_str()?.trim(); - if name.is_empty() { - return None; - } - let description = match std::fs::read_to_string(&path) { - Ok(contents) => contents - .lines() - .next() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_owned), - Err(err) => { - log::debug!("could not read Codex prompt {}: {err}", path.display()); - None - } - }; - Some(ProviderCommand { - name: name.to_owned(), - description, - kind: ProviderCommandKind::Command, - }) - }) - .collect() -} - /// Render a JSON-RPC error object with everything the server sent — message, /// code, and the `data` payload — falling back to the raw JSON when even the /// message is missing. Losing any of it makes provider failures undiagnosable. @@ -3571,49 +3509,6 @@ mod tests { assert_eq!(commands[1].description.as_deref(), Some("charts")); } - #[test] - fn codex_prompt_files_become_slash_commands_from_home_override() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let home = std::env::temp_dir().join(format!("agent-codex-prompts-{nonce}")); - let prompts = home.join("prompts"); - std::fs::create_dir_all(&prompts).unwrap(); - std::fs::write( - prompts.join("review.md"), - "Review the current diff\n\nDo a careful review.", - ) - .unwrap(); - std::fs::write(prompts.join("ship.MD"), "Ship it safely\n").unwrap(); - std::fs::write(prompts.join("empty.md"), "").unwrap(); - std::fs::write(prompts.join("ignored.txt"), "not a prompt").unwrap(); - - let commands = load_codex_prompts(&LaunchEnv { - env: vec![("CODEX_HOME".into(), "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/wrong/home".into())], - home: Some(home.clone()), - }); - assert_eq!( - commands - .iter() - .map(|command| command.name.as_str()) - .collect::>(), - ["empty", "review", "ship"] - ); - assert!( - commands - .iter() - .all(|command| command.kind == ProviderCommandKind::Command) - ); - assert_eq!(commands[0].description, None); - assert_eq!( - commands[1].description.as_deref(), - Some("Review the current diff") - ); - assert_eq!(commands[2].description.as_deref(), Some("Ship it safely")); - let _ = std::fs::remove_dir_all(home); - } - /// A codex binary that dies at startup (the npm-packaging failure mode: /// a JS loader error on stderr, then exit 1) must surface its exit status /// and stderr in the startup error, not just "exited during startup". diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 9d4d6817..78fda2c2 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -749,9 +749,12 @@ pub struct ProviderCommand { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProviderCommandKind { - /// A `/`-command (Claude slash command). + /// A `/`-command, sent as `/name …` at the start of the turn text (Claude + /// `slash_commands`, which include its skills). Command, - /// A `$`-skill (Claude skill / Codex skill). + /// A `$`-skill, sent as a `$name` mention in the turn text (Codex skills). + /// A skill the provider also lists as a command is sent as that command, + /// the only form Claude Code expands. Skill, } diff --git a/crates/runtime/src/app/active_session.rs b/crates/runtime/src/app/active_session.rs index d073c5f8..d645ae7a 100644 --- a/crates/runtime/src/app/active_session.rs +++ b/crates/runtime/src/app/active_session.rs @@ -50,24 +50,65 @@ pub(super) enum QueuedMessageKind { } impl QueuedMessage { - /// The text actually sent to the provider (image-only placeholder and - /// Ultrathink prefix applied). The recorded user message keeps `text` - /// verbatim, so an image-only bubble renders as just its thumbnails. - pub(super) fn wire_text(&self) -> String { + /// The text actually sent to the provider (image-only placeholder, native + /// skill invocation and Ultrathink keyword applied). The recorded user + /// message keeps `text` verbatim, so an image-only bubble renders as just + /// its thumbnails. + pub(super) fn wire_text(&self, provider_commands: &[ProviderCommand]) -> String { let text = if let Some(transcript) = &self.relay_transcript { assemble_relay_prompt(transcript, &self.text) } else { self.text.clone() }; let text = wire_text_with_placeholder(text, &self.attachments); + let text = native_skill_invocation(text, provider_commands); if self.ultrathink { - format!("Ultrathink:\n{text}") + ultrathink_text(text) } else { text } } } +/// Send a leading `$name` skill mention as `/name` when the provider also lists +/// `name` as a slash command. Claude Code reports every user-invocable skill +/// under both `skills` and `slash_commands`, and expands only the `/name` form +/// (at byte 0 of the message); Codex lists skills alone and expands `$name` +/// itself, so its mentions are left as typed. +pub(super) fn native_skill_invocation( + text: String, + provider_commands: &[ProviderCommand], +) -> String { + let Some(rest) = text.strip_prefix('$') else { + return text; + }; + let name_len = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let name = &rest[..name_len]; + let listed = |kind: ProviderCommandKind| { + provider_commands + .iter() + .any(|command| command.kind == kind && command.name == name) + }; + if name.is_empty() + || !listed(ProviderCommandKind::Skill) + || !listed(ProviderCommandKind::Command) + { + return text; + } + format!("/{rest}") +} + +/// Add the `ultrathink` keyword, which Claude Code honours anywhere in the +/// prompt. A message that starts with `/` is a slash command, and the CLI +/// expands one only at byte 0, so there the keyword trails the text instead. +pub(super) fn ultrathink_text(text: String) -> String { + if text.starts_with('/') { + format!("{text}\n\nultrathink") + } else { + format!("Ultrathink:\n{text}") + } +} + /// Providers require non-empty turn text: an image-only message uses a /// synthetic placeholder on the wire while the transcript records the user's /// empty text plus the attachments. @@ -177,7 +218,7 @@ pub struct ActiveSession { /// retention and LRU eviction. Active or working sessions keep this clear. pub(super) idle_since: Option, /// Provider-native commands / skills discovered at session start (Claude - /// `slash_commands` + `skills`; Codex `skills/list` + custom prompts). + /// `slash_commands` + `skills`; Codex `skills/list`). /// Seeded from the per-provider cache, then replaced by live updates. pub(super) provider_commands: Vec, /// The agent's self-described options (ACP `modes` / `models` / @@ -505,7 +546,7 @@ impl ActiveSession { commands .try_send(SessionCommand::SendTurn { delivery_id: send.id, - text: send.wire_text(), + text: send.wire_text(&self.provider_commands), options: Some(send.options), attachments: send.attachments, }) diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 2c0c0685..c33c2c64 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -11,9 +11,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use agent::{ AgentError, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, CatalogRefresh, InteractionMode, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, - OptionDescriptors, OptionSelection, PlanResolution, ProviderCommand, ProviderKind, RewindMode, - SessionCommand, SessionHandle, SessionOptions, ThreadItem, TurnOptions, TurnStatus, - list_models, + OptionDescriptors, OptionSelection, PlanResolution, ProviderCommand, ProviderCommandKind, + ProviderKind, RewindMode, SessionCommand, SessionHandle, SessionOptions, ThreadItem, + TurnOptions, TurnStatus, list_models, }; use base64::Engine as _; use serde::{Deserialize, Serialize}; diff --git a/crates/runtime/src/app/options.rs b/crates/runtime/src/app/options.rs index 67d9efff..fb251a6e 100644 --- a/crates/runtime/src/app/options.rs +++ b/crates/runtime/src/app/options.rs @@ -134,7 +134,7 @@ impl AppState { self.preview_draft_or_persist_active(target_id, cx); } - /// Arm an Ultrathink turn: the next send is prefixed with `Ultrathink:\n`. + /// Arm an Ultrathink turn: the next send carries the `ultrathink` keyword. /// This is a transient per-send flag, not a persisted option. pub fn select_ultrathink(&mut self, target_id: &str, _cx: &mut HostCx) { if let Some(active) = self.resident_mut(target_id) { diff --git a/crates/runtime/src/app/send.rs b/crates/runtime/src/app/send.rs index d206ff71..4c8d3d42 100644 --- a/crates/runtime/src/app/send.rs +++ b/crates/runtime/src/app/send.rs @@ -1,3 +1,4 @@ +use super::active_session::{native_skill_invocation, ultrathink_text}; use super::*; impl AppState { @@ -601,8 +602,9 @@ impl AppState { SendRouting::Steer => { let session_id = active.meta.id.clone(); let wire_text = wire_text_with_placeholder(text.clone(), &attachments); + let wire_text = native_skill_invocation(wire_text, &active.provider_commands); let wire_text = if active.pending_ultrathink { - format!("Ultrathink:\n{wire_text}") + ultrathink_text(wire_text) } else { wire_text }; diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index f4cc40e5..4707b0c6 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -5009,6 +5009,49 @@ fn send_routing_matrix() { assert_eq!(dead.route(true), SendRouting::QueueUnsupported); } +/// Claude Code expands a slash command only at byte 0 of the message, so the +/// Ultrathink keyword trails a command instead of displacing it. +#[test] +fn ultrathink_keyword_trails_a_slash_command() { + assert_eq!(ultrathink_text("deep".into()), "Ultrathink:\ndeep"); + assert_eq!( + ultrathink_text("/review the diff".into()), + "/review the diff\n\nultrathink" + ); +} + +/// A `$skill` the provider also exposes as a slash command (Claude) goes out +/// as that command; a skill-only mention (Codex) and an unknown `$word` are +/// sent as typed. +#[test] +fn skill_mentions_use_the_providers_native_invocation() { + let command = |name: &str, kind| ProviderCommand { + name: name.into(), + description: None, + kind, + }; + let claude = [ + command("review", ProviderCommandKind::Command), + command("review", ProviderCommandKind::Skill), + ]; + let codex = [command("review", ProviderCommandKind::Skill)]; + assert_eq!( + native_skill_invocation("$review the diff".into(), &claude), + "/review the diff" + ); + assert_eq!( + native_skill_invocation("$review".into(), &claude), + "/review" + ); + assert_eq!( + native_skill_invocation("$review the diff".into(), &codex), + "$review the diff" + ); + for text in ["$HOME is set", "please $review", "$"] { + assert_eq!(native_skill_invocation(text.into(), &claude), text); + } +} + /// Ultrathink is per-send: it rides with the message it was armed for, not /// with whatever happens to be dispatched later. #[test] diff --git a/crates/ui/src/composer/components/trigger_menu.rs b/crates/ui/src/composer/components/trigger_menu.rs index 0b65a2c3..a5cfe55a 100644 --- a/crates/ui/src/composer/components/trigger_menu.rs +++ b/crates/ui/src/composer/components/trigger_menu.rs @@ -1,8 +1,19 @@ use super::super::*; impl Composer { - pub(in super::super) fn menu_visible(&self) -> bool { - self.active_trigger.is_some() && !self.menu_dismissed + /// Whether the trigger menu is shown and owns Enter/arrows. A `/` or `$` + /// token that matches nothing is ordinary text (a path, a shell variable, + /// a provider command Tcode does not know), so no menu opens for it and + /// Enter submits as usual; only the asynchronous `@` menu keeps an empty + /// or loading state. + pub(in super::super) fn menu_visible(&self, cx: &App) -> bool { + let Some(trigger) = self.active_trigger.as_ref() else { + return false; + }; + if self.menu_dismissed { + return false; + } + trigger.kind == TriggerKind::Path || !self.menu_rows(cx).0.is_empty() } /// Recompute the active trigger from the input text + cursor, resetting the @@ -103,7 +114,7 @@ impl Composer { group: Some("composer.group_skills"), }) .collect(); - (rows, crate::tr!("composer.no_skills").into_owned(), false) + (rows, String::new(), false) } TriggerKind::SlashCommand | TriggerKind::SlashModel => { let builtins: [(&str, Option<&str>, &str, MenuAccept); 5] = [ @@ -154,8 +165,9 @@ impl Composer { group: Some("composer.group_builtin"), }) .collect(); - // Provider-native slash commands (Claude `slash_commands`), shown - // after the built-in group, fuzzily filtered without truncation. + // Provider-native slash commands (Claude `slash_commands`, which + // include its skills), shown after the built-in group, fuzzily + // filtered without truncation. let commands = self .workspace_store .read(cx) @@ -178,7 +190,7 @@ impl Composer { group: Some("composer.group_provider"), }), ); - (rows, crate::tr!("composer.no_command").into_owned(), false) + (rows, String::new(), false) } } } @@ -244,7 +256,7 @@ impl Composer { &self, cx: &mut Context, ) -> Option { - if !self.menu_visible() { + if !self.menu_visible(cx) { return None; } let (rows, empty_text, loading) = self.menu_rows(cx); @@ -466,6 +478,33 @@ mod tests { } } + /// A `/` or `$` token that matches nothing is ordinary text: no menu opens, + /// so Enter submits it instead of accepting a row that does not exist. + #[gpui::test] + fn unmatched_command_and_skill_tokens_open_no_menu(cx: &mut TestAppContext) { + let (_store, composer, cx) = composer_window(false, cx); + cx.simulate_resize(size(px(800.), px(600.))); + composer.update_in(cx, |composer, window, cx| { + window.focus(&composer.input.read(cx).focus_handle(cx), cx); + }); + for (text, visible) in [ + ("/mod", true), + ("/nosuchcommand", false), + ("$HOME", false), + ("/Users/me/notes.md", false), + ] { + composer.update_in(cx, |composer, window, cx| { + composer.set_draft("", window, cx) + }); + cx.simulate_input(text); + cx.update(|window, cx| _ = window.draw(cx)); + composer.read_with(cx, |composer, cx| { + assert_eq!(composer.menu_visible(cx), visible, "{text:?}"); + }); + assert_eq!(cx.debug_bounds("menu-row-0").is_some(), visible, "{text:?}"); + } + } + #[gpui::test] fn arrow_keys_keep_the_highlighted_mention_laid_out(cx: &mut TestAppContext) { let (store, composer, cx) = composer_window(false, cx); diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 5407a0bd..2ee40f8a 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -335,7 +335,7 @@ impl Composer { // injected into the turn that is already running rather // than held until it finishes. With no turn running the // two are equivalent (there is nothing to steer into). - if this.menu_visible() { + if this.menu_visible(cx) { this.accept_menu(this.menu_highlight, window, cx); } else { let input = input.clone(); @@ -1305,7 +1305,7 @@ impl Render for Composer { cx.stop_propagation(); return; } - if key == "escape" && this.menu_visible() { + if key == "escape" && this.menu_visible(cx) { this.menu_dismissed = true; cx.notify(); } @@ -1313,14 +1313,14 @@ impl Render for Composer { // The editor binds Up and Down to cursor moves, which consume the // keystroke before any key-down listener runs. .capture_action(cx.listener(|this, _: &MoveUp, _, cx| { - if this.menu_visible() { + if this.menu_visible(cx) { this.menu_highlight = this.menu_highlight.saturating_sub(1); cx.stop_propagation(); cx.notify(); } })) .capture_action(cx.listener(|this, _: &MoveDown, _, cx| { - if this.menu_visible() { + if this.menu_visible(cx) { let (rows, _, _) = this.menu_rows(cx); this.menu_highlight = (this.menu_highlight + 1).min(rows.len().saturating_sub(1)); diff --git a/crates/ui/src/composer_trigger.rs b/crates/ui/src/composer_trigger.rs index 641cbd04..d34cd089 100644 --- a/crates/ui/src/composer_trigger.rs +++ b/crates/ui/src/composer_trigger.rs @@ -34,40 +34,44 @@ fn is_ws(b: u8) -> bool { /// Detect an active trigger at `cursor` (a UTF-8 byte offset into `text`). /// -/// `/` is recognized at the start of a line; `@` and `$` follow whitespace. +/// `/` is recognized only at the start of the message: Claude Code expands a +/// command only there, and Tcode's own commands are whole-message too. A `/` +/// token with a second `/` in it is a path. `@` and `$` follow whitespace. pub fn detect_composer_trigger(text: &str, cursor: usize) -> Option { let cursor = cursor.min(text.len()); // Snap to a char boundary defensively (byte offsets from the input are on // boundaries, but clamping above could land mid-char in pathological input). let cursor = (0..=cursor).rev().find(|&i| text.is_char_boundary(i))?; - let line_start = text[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0); - let line_prefix = &text[line_start..cursor]; + let prefix = &text[..cursor]; - if let Some(rest) = line_prefix.strip_prefix('/') { - // `^/(\S*)$`: a slash command with no whitespace after the slash. + if let Some(rest) = prefix.strip_prefix('/') { + // `^/([^\s/]*)$`: a slash command with no whitespace after the slash. if !rest.bytes().any(is_ws) { + if rest.contains('/') { + return None; + } if rest.eq_ignore_ascii_case("model") { return Some(ComposerTrigger { kind: TriggerKind::SlashModel, query: String::new(), - range: line_start..cursor, + range: 0..cursor, }); } return Some(ComposerTrigger { kind: TriggerKind::SlashCommand, query: rest.to_string(), - range: line_start..cursor, + range: 0..cursor, }); } // `^/model(?:\s+(.*))?$`: `/model `. - if let Some(after) = line_prefix.strip_prefix("/model") + if let Some(after) = prefix.strip_prefix("/model") && after.starts_with(|c: char| c.is_whitespace()) { return Some(ComposerTrigger { kind: TriggerKind::SlashModel, query: after.trim().to_string(), - range: line_start..cursor, + range: 0..cursor, }); } // A `/word …` that is not a bare command and not `/model`: fall through. @@ -154,7 +158,8 @@ mod tests { ("\t@文件 suffix", 5, TriggerKind::Path, "文", 1..5), ("@文", 2, TriggerKind::Path, "", 0..1), ("@file", usize::MAX, TriggerKind::Path, "file", 0..5), - ("hi\n/de", 6, TriggerKind::SlashCommand, "de", 3..6), + ("/de", 3, TriggerKind::SlashCommand, "de", 0..3), + ("/a:b", 4, TriggerKind::SlashCommand, "a:b", 0..4), ("/", 1, TriggerKind::SlashCommand, "", 0..1), ("/model", 6, TriggerKind::SlashModel, "", 0..6), ("/MODEL", 6, TriggerKind::SlashModel, "", 0..6), @@ -176,7 +181,10 @@ mod tests { "cost$rev", "hello /pla", " /pla", + "hi\n/de", "/plan done", + "/Users/me/file.txt", + "/tmp/", "@file ", ] { assert_eq!(detect_composer_trigger(text, text.len()), None, "{text:?}"); diff --git a/locales/en.yml b/locales/en.yml index 22e41763..788ee4b1 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -684,8 +684,6 @@ composer: steer_tooltip: "Steer the current turn" compacts_automatically: "%{provider} automatically compacts its context when needed." no_files: "No matching files or folders." - no_skills: "No skills found. Try / to browse provider commands." - no_command: "No matching command." searching: "Searching…" cmd_model_desc: "Open the model picker" cmd_plan_desc: "Switch this thread into plan mode" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 2354e0a1..35cda330 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -678,8 +678,6 @@ composer: steer_tooltip: "引导当前轮次" compacts_automatically: "%{provider} 会在需要时自动压缩其上下文。" no_files: "没有匹配的文件或文件夹。" - no_skills: "未找到技能。试试用 / 浏览提供方命令。" - no_command: "没有匹配的命令。" searching: "搜索中…" cmd_model_desc: "打开模型选择器" cmd_plan_desc: "将此会话切换到计划模式"