diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index a30f5977a..a740fd982 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -6880,12 +6880,17 @@ impl App { .session_id .clone() .unwrap_or_else(|| DEFAULT_TAB_ID.to_string()); + // Extract @pane-ref tokens before building context so the + // ACP client task can resolve them to pane ids and inject + // the corresponding pane output into the prompt body. + let at_pane_refs = crate::pane_context::extract_at_refs(&text); let pane_context = PaneContext { pane_id: self.pane_id.clone(), tab_id: self.tab_id.clone(), window_id: self.window_id.clone(), cwd: None, source_pane_id: None, + at_pane_refs, }; let prompt = PromptSubmission::new(text.clone(), Some(pane_context)); prompt_timing_log( @@ -7272,6 +7277,7 @@ impl App { cwd: None, // None → the client task resolves the active working pane itself. source_pane_id: None, + at_pane_refs: Vec::new(), }; let hint = hint.trim().to_string(); diff --git a/tools/wta/src/app/autofix.rs b/tools/wta/src/app/autofix.rs index 1e983f91e..ef5306ef2 100644 --- a/tools/wta/src/app/autofix.rs +++ b/tools/wta/src/app/autofix.rs @@ -241,6 +241,7 @@ impl App { window_id: self.window_id.clone(), cwd: None, source_pane_id: Some(notification.pane_id.clone()), + at_pane_refs: Vec::new(), }; // Store the failing pane ID on the target tab so the Esc dismiss diff --git a/tools/wta/src/pane_context.rs b/tools/wta/src/pane_context.rs index 010d8773e..75f20fef2 100644 --- a/tools/wta/src/pane_context.rs +++ b/tools/wta/src/pane_context.rs @@ -7,6 +7,12 @@ pub struct PaneContext { pub window_id: Option, pub cwd: Option, pub source_pane_id: Option, + /// Raw `@`-mention tokens from the user's message (e.g. `["@1", "@build"]`). + /// Each token is resolved asynchronously by the ACP client task to a pane + /// id (by index or title match), and that pane's recent output is injected + /// into the prompt as additional context. Empty for most prompts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub at_pane_refs: Vec, } impl PaneContext { @@ -15,6 +21,44 @@ impl PaneContext { } } +/// Parse all `@word` tokens from a user message string. +/// +/// Returns the unique tokens in the order they first appear. Tokens that +/// contain only digits (e.g. `@1`) are intended as 1-based pane index +/// references; all other tokens are matched against pane titles. +/// +/// The `@` sigil must be immediately followed by at least one word +/// character (letter, digit, `-`, or `_`). A bare `@` or `@ ` is ignored. +pub fn extract_at_refs(text: &str) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + let chars: Vec = text.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '@' { + let start = i + 1; + let mut end = start; + // Consume word chars: letters, digits, hyphens, underscores. + while end < chars.len() + && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_') + { + end += 1; + } + if end > start { + let token: String = chars[start..end].iter().collect(); + let at_token = format!("@{}", token); + if seen.insert(at_token.clone()) { + result.push(at_token); + } + } + i = end; + } else { + i += 1; + } + } + result +} + #[cfg(test)] mod tests { use super::*; @@ -43,4 +87,40 @@ mod tests { // Neither → None (must not invent a target pane). assert_eq!(ctx(None, None).effective_source_pane_id(), None); } + + #[test] + fn extract_at_refs_basic() { + let refs = extract_at_refs("look at @pane2 and @build"); + assert_eq!(refs, vec!["@pane2", "@build"]); + } + + #[test] + fn extract_at_refs_index() { + let refs = extract_at_refs("what is happening in @1?"); + assert_eq!(refs, vec!["@1"]); + } + + #[test] + fn extract_at_refs_deduplicates() { + let refs = extract_at_refs("@pane1 and @pane1 again"); + assert_eq!(refs, vec!["@pane1"]); + } + + #[test] + fn extract_at_refs_bare_at_ignored() { + let refs = extract_at_refs("send @ me"); + assert!(refs.is_empty()); + } + + #[test] + fn extract_at_refs_allows_hyphens_and_underscores() { + let refs = extract_at_refs("check @my-pane and @some_pane"); + assert_eq!(refs, vec!["@my-pane", "@some_pane"]); + } + + #[test] + fn extract_at_refs_empty_input() { + assert!(extract_at_refs("").is_empty()); + assert!(extract_at_refs("no mentions here").is_empty()); + } } diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index aeaf67f53..a935bd743 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -1232,6 +1232,47 @@ fn user_locale_tag() -> String { rust_i18n::locale().to_string() } +/// Resolve an `@`-mention token to a WT pane `session_id`. +/// +/// `token` is the raw text after the `@` sigil (e.g. `"1"`, `"build"`). +/// `pane_list` is the `list_panes` JSON response for the current tab. +/// +/// Resolution rules (first match wins): +/// 1. If `token` is a non-zero unsigned integer N, return the N-th pane +/// (1-based) from the `panes` array. +/// 2. Otherwise case-insensitively prefix-match the `title` field of each +/// pane entry; return the first match's `session_id`. +/// +/// Returns `None` when WT is unavailable, no pane list was fetched, or the +/// token matches nothing. +fn resolve_at_pane_ref(token: &str, pane_list: Option<&serde_json::Value>) -> Option { + let list = pane_list?; + let panes = list.get("panes")?.as_array()?; + + // Numeric index (1-based)? + if let Ok(idx) = token.parse::() { + if idx > 0 { + return panes + .get(idx - 1) + .and_then(|p| json_str_or_num(p.get("session_id"))); + } + } + + // Title prefix match (case-insensitive). + let needle = token.to_ascii_lowercase(); + for pane in panes { + let title = pane + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !title.is_empty() && title.starts_with(&needle) { + return json_str_or_num(pane.get("session_id")); + } + } + None +} + async fn build_prompt_text( prompt_id: u64, submitted_at_unix_s: f64, @@ -1392,6 +1433,72 @@ async fn build_prompt_text( } } + // @pane-ref context injection. + // + // The user may have typed tokens like `@1` or `@build` in their message. + // These are stored in `PaneContext::at_pane_refs` by the UI layer. Here + // we resolve each token to a real pane id (by 1-based index or title + // prefix match against the current tab's pane list) and append that + // pane's recent output as an extra `### Pane Context` section. This + // works for both planner and autofix prompts, though autofix prompts + // rarely carry @-refs in practice. + if wt_connected { + let at_refs = pane_context + .map(|c| c.at_pane_refs.clone()) + .unwrap_or_default(); + if !at_refs.is_empty() { + let tab_id = pane_context.and_then(|c| c.tab_id.as_deref()).unwrap_or(""); + let at_ctx_started = std::time::Instant::now(); + let pane_list = if tab_id.is_empty() { + None + } else { + shell_mgr.wt_list_panes(tab_id).await.ok() + }; + for at_ref in &at_refs { + let token = at_ref.trim_start_matches('@'); + // Resolve to a pane id: first try numeric index, then title prefix. + let resolved_pane_id = resolve_at_pane_ref(token, pane_list.as_ref()); + if let Some(pane_id) = resolved_pane_id { + tracing::debug!( + target: "acp.terminal_context", + at_ref = %at_ref, + pane_id = %pane_id, + "at_ref_pane_resolved" + ); + if let Some(content) = read_pane_last_message( + shell_mgr, + &pane_id, + 30, + ACTIVE_PANE_CONTEXT_MAX_CHARS, + ) + .await + { + runtime_sections.push(format!( + "### Pane Context ({})\n```\n{}\n```", + at_ref, content + )); + } + } else { + tracing::debug!( + target: "acp.terminal_context", + at_ref = %at_ref, + "at_ref_pane_unresolved" + ); + } + } + prompt_timing_log( + prompt_id, + submitted_at_unix_s, + "at_pane_refs_ready", + &format!( + "refs={} dt={:.3}s", + at_refs.len(), + at_ctx_started.elapsed().as_secs_f64() + ), + ); + } + } + let assemble_started = std::time::Instant::now(); // First turn of a session (or kind change): ship the full template // body. Subsequent same-kind turns drop the template — the agent @@ -4764,6 +4871,67 @@ mod tests { assert_eq!(super::json_str_or_num(None), None); } + #[test] + fn resolve_at_pane_ref_by_index() { + use serde_json::json; + let list = json!({ + "panes": [ + { "session_id": "pane-aaa", "title": "PowerShell" }, + { "session_id": "pane-bbb", "title": "Build" }, + { "session_id": "pane-ccc", "title": "Test" }, + ] + }); + assert_eq!( + super::resolve_at_pane_ref("1", Some(&list)).as_deref(), + Some("pane-aaa") + ); + assert_eq!( + super::resolve_at_pane_ref("2", Some(&list)).as_deref(), + Some("pane-bbb") + ); + assert_eq!( + super::resolve_at_pane_ref("3", Some(&list)).as_deref(), + Some("pane-ccc") + ); + // Out of range. + assert_eq!(super::resolve_at_pane_ref("4", Some(&list)), None); + // Index 0 is explicitly rejected (1-based). + assert_eq!(super::resolve_at_pane_ref("0", Some(&list)), None); + } + + #[test] + fn resolve_at_pane_ref_by_title_prefix() { + use serde_json::json; + let list = json!({ + "panes": [ + { "session_id": "pane-aaa", "title": "PowerShell" }, + { "session_id": "pane-bbb", "title": "Build" }, + ] + }); + // Exact match. + assert_eq!( + super::resolve_at_pane_ref("Build", Some(&list)).as_deref(), + Some("pane-bbb") + ); + // Case-insensitive prefix. + assert_eq!( + super::resolve_at_pane_ref("build", Some(&list)).as_deref(), + Some("pane-bbb") + ); + assert_eq!( + super::resolve_at_pane_ref("pow", Some(&list)).as_deref(), + Some("pane-aaa") + ); + // No match. + assert_eq!(super::resolve_at_pane_ref("xyz", Some(&list)), None); + } + + #[test] + fn resolve_at_pane_ref_no_list() { + assert_eq!(super::resolve_at_pane_ref("1", None), None); + assert_eq!(super::resolve_at_pane_ref("build", None), None); + } + /// Test the helper's mirror of master's session-broadcast feed. /// /// `WtaClient::ext_notification` is the helper's sole inbound path