mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
a8468330bb
commit
76d98a771e
30 files changed
+1317
-44
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+13
@@ -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 "
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› @Review database migration "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
Reference in new issue
Block a user