Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tools/wta/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions tools/wta/src/app/autofix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tools/wta/src/pane_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ pub struct PaneContext {
pub window_id: Option<String>,
pub cwd: Option<String>,
pub source_pane_id: Option<String>,
/// 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<String>,
}

impl PaneContext {
Expand All @@ -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<String> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
let chars: Vec<char> = 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;
}
Comment on lines +32 to +46
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::*;
Expand Down Expand Up @@ -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());
}
}
168 changes: 168 additions & 0 deletions tools/wta/src/protocol/acp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,47 @@
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

Check failure

Code scanning / check-spelling

Forbidden Pattern Error

Otherwise matches a line_forbidden.patterns rule: Should probably be 'Otherwise,' - '\(?<=\\\. \)Otherwise\\\s'
/// 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<String> {
let list = pane_list?;
let panes = list.get("panes")?.as_array()?;

// Numeric index (1-based)?
if let Ok(idx) = token.parse::<usize>() {
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
}
Comment on lines +1248 to +1274

async fn build_prompt_text(
prompt_id: u64,
submitted_at_unix_s: f64,
Expand Down Expand Up @@ -1392,6 +1433,72 @@
}
}

// @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();
Comment on lines +1446 to +1448
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
{
Comment on lines +1457 to +1475
runtime_sections.push(format!(
"### Pane Context ({})\n```\n{}\n```",
Comment on lines +1476 to +1477
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
Expand Down Expand Up @@ -4764,6 +4871,67 @@
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
Expand Down
Loading