Add task mentions to the TUI composer (#40315)

## What changed

- Include matching Codex tasks in the `@` mention popup when the active thread supports task tools, prioritizing tasks from the current working directory.
- Submit selected tasks as bounded live thread references so Codex can load their contents with `read_thread`.
- Preserve task mentions across composer history and thread start, resume, and fork flows.

## Testing

- Add coverage for task search and capability propagation, popup selection and rendering, submission encoding, history round-trips, and reference validation and limits.

GitOrigin-RevId: 17502be91bf6d4b01745668829eb69fb318cb5b9
This commit is contained in:
Eric Traut authored and copyberry committed 2026-08-24 02:46:56 +00:00
1 parent a8468330bb
commit 76d98a771e
30 files changed
+1317 -44

No files matched your search

+189 -7
View File
@@ -3,7 +3,7 @@
//! It is responsible for:
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
//! - Routing keys to the active popup (slash commands, file search, skill/apps/task mentions).
//! - Promoting typed slash commands into atomic elements when the command name is completed.
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
@@ -41,12 +41,13 @@
//!
//! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges:
//!
//! - Persistent cross-session history (text-only; no element ranges or attachments).
//! - Persistent cross-session history (text-only storage; task links recover element ranges and
//! mention bindings, but attachments cannot be restored).
//! - Local in-session history (full text + text elements + local/remote image attachments).
//!
//! When recalling a local entry, the composer rehydrates text elements and both attachment kinds
//! (local image paths + remote image URLs).
//! When recalling a persistent entry, only the text is restored.
//! When recalling a persistent entry, encoded task links restore atomic elements and bindings.
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
//! history traversal semantics instead of dropping to column 0.
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
@@ -500,6 +501,7 @@ pub(crate) struct ChatComposer {
pending_slash_command_history: Option<HistoryEntry>,
skills: Option<Vec<SkillMetadata>>,
plugins: Option<Vec<PluginCapabilitySummary>>,
task_mentions: Option<Vec<crate::task_mentions::TaskMention>>,
connectors_snapshot: Option<ConnectorsSnapshot>,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
@@ -672,6 +674,7 @@ impl ChatComposer {
pending_slash_command_history: None,
skills: None,
plugins: None,
task_mentions: None,
connectors_snapshot: None,
collaboration_modes_enabled: false,
config,
@@ -767,6 +770,47 @@ impl ChatComposer {
self.sync_popups();
}
pub(crate) fn set_task_mentions_enabled(&mut self, enabled: bool) {
self.task_mentions = enabled.then(Vec::new);
self.refresh_mentions_v2_popup_candidates();
if enabled
&& matches!(self.popups.active, ActivePopup::MentionV2(_))
&& let Some(query) = self.current_mentions_v2_token()
&& !query.is_empty()
{
self.app_event_tx.send(AppEvent::StartFileSearch(query));
}
}
pub(crate) fn task_mentions_enabled(&self) -> bool {
self.task_mentions.is_some()
}
pub(crate) fn on_task_search_result(
&mut self,
query: &str,
mut matches: Vec<crate::task_mentions::TaskMention>,
) {
if self.current_mentions_v2_token().as_deref() != Some(query) {
return;
}
let mentioned_ids = self
.current_mention_elements()
.into_iter()
.filter_map(|(id, _, _)| self.draft.mention_bindings.get(&id))
.filter_map(|binding| crate::task_mentions::valid_thread_path(&binding.path))
.collect::<std::collections::HashSet<_>>();
matches.retain(|task| {
mentioned_ids.len() < crate::task_mentions::MAX_REFERENCED_TASKS
&& !mentioned_ids.contains(task.thread_id.as_str())
});
let Some(task_mentions) = &mut self.task_mentions else {
return;
};
*task_mentions = matches;
self.refresh_mentions_v2_popup_candidates();
}
/// Refreshes an open mention catalog when skill or plugin metadata changes.
fn refresh_mentions_v2_popup_candidates(&mut self) {
let ActivePopup::MentionV2(popup) = &mut self.popups.active else {
@@ -775,6 +819,7 @@ impl ChatComposer {
popup.set_candidates(super::mentions_v2::build_search_catalog(
self.skills.as_deref(),
self.plugins.as_deref(),
self.task_mentions.as_deref().unwrap_or_default(),
));
}
@@ -2727,9 +2772,18 @@ impl ChatComposer {
let id = self.draft.textarea.insert_element(insert_text);
let inserted_range = start_idx..start_idx.saturating_add(insert_text.len());
if let (Some(path), Some((sigil, mention))) =
(path, Self::mention_token_from_insert_text(insert_text))
let mention = if path
.and_then(crate::task_mentions::valid_thread_path)
.is_some()
{
insert_text
.strip_prefix('@')
.filter(|name| !name.is_empty())
.map(|name| ('@', name.to_string()))
} else {
Self::mention_token_from_insert_text(insert_text)
};
if let (Some(path), Some((sigil, mention))) = (path, mention) {
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
@@ -2739,6 +2793,13 @@ impl ChatComposer {
},
);
}
if path
.and_then(crate::task_mentions::valid_thread_path)
.is_some()
&& let Some(task_mentions) = &mut self.task_mentions
{
task_mentions.clear();
}
self.advance_past_completion_separator();
if let Some(sigil) = insert_text.chars().next()
@@ -2796,6 +2857,12 @@ impl ChatComposer {
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
if let Some(binding) = self.draft.mention_bindings.get(&snapshot.id)
&& crate::task_mentions::valid_thread_path(&binding.path).is_some()
&& snapshot.text == format!("{}{}", binding.sigil, binding.mention)
{
return Some((snapshot.id, binding.sigil, binding.mention.clone()));
}
Self::mention_token_from_insert_text(snapshot.text.as_str())
.map(|(sigil, mention)| (snapshot.id, sigil, mention))
})
@@ -2829,9 +2896,17 @@ impl ChatComposer {
let mut scan_from = 0usize;
for binding in mention_bindings {
let token = format!("{}{}", binding.sigil, binding.mention);
let Some(range) =
let range = if crate::task_mentions::valid_thread_path(&binding.path).is_some() {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.find(|element| element.range.start >= scan_from && element.text == token)
.map(|element| element.range)
} else {
find_next_mention_token_range(text.as_str(), token.as_str(), scan_from)
else {
};
let Some(range) = range else {
continue;
};
@@ -4098,6 +4173,7 @@ impl ChatComposer {
let candidates = super::mentions_v2::build_search_catalog(
self.skills.as_deref(),
self.plugins.as_deref(),
self.task_mentions.as_deref().unwrap_or_default(),
);
self.popups.active =
ActivePopup::MentionV2(MentionV2Popup::new(candidates, &query));
@@ -7567,6 +7643,112 @@ mod tests {
);
}
#[test]
fn task_mention_popup_and_selected_composer_snapshots() {
for selected in [false, true] {
snapshot_composer_state(
if selected {
"task_mention_selected_composer"
} else {
"task_mention_popup"
},
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_task_mentions_enabled(/*enabled*/ true);
composer.set_text_content("@migr".to_string(), Vec::new(), Vec::new());
composer.on_task_search_result(
"migr",
vec![
crate::task_mentions::TaskMention {
thread_id: "task-123".to_string(),
title: "Review database migration".to_string(),
cwd: "/workspace/project".to_string(),
snippet: "database migration".to_string(),
},
crate::task_mentions::TaskMention {
thread_id: "task-456".to_string(),
title: "migration".to_string(),
cwd: "/another/project".to_string(),
snippet: "migration".to_string(),
},
],
);
if selected {
composer
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(composer.mention_bindings().as_slice(),
[MentionBinding { mention, path, .. }]
if mention == "Review database migration"
&& path == "thread://task-123"));
assert!(composer.task_mentions.as_ref().is_some_and(Vec::is_empty));
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
assert_eq!(buf[(2, 1)].style().fg, Some(Color::Cyan));
}
},
);
}
}
#[test]
fn task_mention_titles_are_normalized_and_bounded_before_insertion() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_task_mentions_enabled(/*enabled*/ true);
composer.set_text_content("@task".to_string(), Vec::new(), Vec::new());
let task = crate::task_mentions::TaskMention {
thread_id: "task-123".to_string(),
title: format!("task\n\t{}é{}", "a".repeat(154), "é".repeat(200)),
cwd: "/workspace/project".to_string(),
snippet: String::new(),
};
composer.on_task_search_result("task", vec![task.clone()]);
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
composer.mention_bindings(),
vec![MentionBinding {
sigil: '@',
mention: format!("task {}é", "a".repeat(154)),
path: "thread://task-123".to_string(),
}]
);
composer.draft.textarea.set_text_clearing_elements("@task");
composer.sync_popups();
composer.on_task_search_result("task", vec![task]);
assert!(matches!(&composer.popups.active,
ActivePopup::MentionV2(popup) if popup.selected().is_some()));
let history = HistoryEntry::new(
"[@sample](plugin://sample@test) @sample [@sample](thread://task-123)x".to_string(),
);
composer.set_text_content_with_mention_bindings(
history.text,
history.text_elements,
Vec::new(),
history.mention_bindings,
);
assert_eq!(
composer.mention_bindings(),
["plugin://sample@test", "thread://task-123"]
.into_iter()
.map(|path| MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: path.to_string(),
})
.collect::<Vec<_>>()
);
assert_eq!(
composer.draft.textarea.text_element_snapshots()[1]
.range
.start,
"@sample @sample ".len()
);
}
#[test]
fn bare_unified_mention_resets_an_inherited_file_search() {
let (mut composer, mut rx) = new_test_composer();
@@ -55,8 +55,8 @@ pub(crate) struct HistoryEntry {
impl HistoryEntry {
/// Creates a text-only history entry and decodes persisted mention bindings.
///
/// Persistent history does not store attachment payloads or text-element metadata, so this
/// constructor intentionally leaves those fields empty. Local in-session submissions should be
/// Persistent history does not store attachments or original element metadata; selected task
/// elements are recovered from encoded links. Local in-session submissions should be
/// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local
/// image or paste submission would make recall lose placeholder ownership.
pub(crate) fn new(text: String) -> Self {
@@ -65,9 +65,14 @@ impl HistoryEntry {
pub(crate) fn new_with_at_mentions(text: String, at_mentions_enabled: bool) -> Self {
let decoded = decode_history_mentions_with_at_mentions(&text, at_mentions_enabled);
let text_elements = decoded
.task_mention_ranges
.into_iter()
.map(|range| TextElement::new(range.into(), /*placeholder*/ None))
.collect();
Self {
text: decoded.text,
text_elements: Vec::new(),
text_elements,
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: decoded
@@ -6,7 +6,7 @@ use ratatui::text::Span;
const TAG_WIDTH: usize = "Plugin".len();
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Selection {
File(PathBuf),
Tool {
@@ -19,6 +19,7 @@ pub(crate) enum Selection {
pub(super) enum MentionType {
Plugin,
Skill,
Task,
File,
Directory,
}
@@ -32,6 +33,7 @@ impl MentionType {
let style = match self {
Self::Plugin => base_style.magenta(),
Self::Skill => base_style.dim(),
Self::Task => base_style.cyan(),
Self::File => base_style.cyan(),
Self::Directory => base_style,
};
@@ -42,6 +44,7 @@ impl MentionType {
match self {
Self::Plugin => "Plugin",
Self::Skill => "Skill",
Self::Task => "Task",
Self::File => "File",
Self::Directory => "Dir",
}
@@ -63,10 +63,14 @@ fn sort_rows(rows: &mut [SearchResult], filter: &str) {
let type_order = |mention_type: MentionType| match mention_type {
MentionType::Plugin => 0,
MentionType::Skill => 1,
MentionType::File | MentionType::Directory => 2,
MentionType::Task => 2,
MentionType::File | MentionType::Directory => 3,
};
rows.sort_by(|a, b| {
if a.mention_type == MentionType::Task && b.mention_type == MentionType::Task {
return std::cmp::Ordering::Equal;
}
type_order(a.mention_type)
.cmp(&type_order(b.mention_type))
.then_with(|| compare_within_rank(a, b, filter))
@@ -38,8 +38,16 @@ impl Popup {
}
pub(crate) fn set_candidates(&mut self, candidates: Vec<Candidate>) {
let selection = self.selected();
self.candidates = candidates;
self.refresh_rows();
if let Some(selection) = selection
&& let Some(index) = self.rows.iter().position(|row| row.selection == selection)
{
self.state.selected_idx = Some(index);
self.state
.ensure_visible(self.rows.len(), MAX_POPUP_ROWS.min(self.rows.len()));
}
}
pub(crate) fn set_query(&mut self, query: &str) {
@@ -187,6 +187,7 @@ fn primary_spans(row: &SearchResult, base_style: Style) -> Vec<Span<'static>> {
let name_style = match row.mention_type {
MentionType::Plugin => base_style.magenta(),
MentionType::Skill => base_style.dim(),
MentionType::Task => base_style.cyan(),
MentionType::File | MentionType::Directory => base_style,
};
if let Some(indices) = row.match_indices.as_ref() {
@@ -3,6 +3,8 @@ use codex_plugin::PluginCapabilitySummary;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use crate::task_mentions::MAX_TASK_TITLE_CHARS;
use crate::task_mentions::TaskMention;
use super::candidate::Candidate;
use super::candidate::MentionType;
@@ -11,6 +13,7 @@ use super::candidate::Selection;
pub(crate) fn build_search_catalog(
skills: Option<&[SkillMetadata]>,
plugins: Option<&[PluginCapabilitySummary]>,
tasks: &[TaskMention],
) -> Vec<Candidate> {
let mut candidates = Vec::new();
if let Some(skills) = skills {
@@ -21,6 +24,25 @@ pub(crate) fn build_search_catalog(
candidates.extend(plugins.iter().map(plugin_candidate));
}
candidates.extend(tasks.iter().map(|task| {
let title = task.title.split_whitespace().collect::<Vec<_>>().join(" ");
let title: String = title.chars().take(MAX_TASK_TITLE_CHARS).collect();
Candidate {
display_name: title.clone(),
description: (!task.cwd.is_empty()).then(|| task.cwd.clone()),
search_terms: vec![
title.clone(),
task.cwd.chars().take(MAX_TASK_TITLE_CHARS).collect(),
task.snippet.chars().take(MAX_TASK_TITLE_CHARS).collect(),
],
mention_type: MentionType::Task,
selection: Selection::Tool {
insert_text: format!("@{title}"),
path: Some(format!("thread://{}", task.thread_id)),
},
}
}));
candidates
}
+18
View File
@@ -370,6 +370,24 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn set_task_mentions_enabled(&mut self, enabled: bool) {
self.composer.set_task_mentions_enabled(enabled);
self.request_redraw();
}
pub(crate) fn task_mentions_enabled(&self) -> bool {
self.composer.task_mentions_enabled()
}
pub(crate) fn on_task_search_result(
&mut self,
query: &str,
matches: Vec<crate::task_mentions::TaskMention>,
) {
self.composer.on_task_search_result(query, matches);
self.request_redraw();
}
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
self.composer.set_plugins_command_enabled(enabled);
self.request_redraw();
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› @migr "
" "
" "
" "
"> Review database migration /workspace/project Task "
" migration /another/project Task "
" "
" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› @Review database migration "
" "
" "
" "
" "
" "
" "
" 100% context left "