From ea5565d644d94099da420a5e8901d6ec004bdb25 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 9 May 2026 13:39:04 -0300 Subject: [PATCH] tui: hide app git directives in transcripts --- codex-rs/tui/src/app/event_dispatch.rs | 8 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/app_server_session.rs | 25 +++ codex-rs/tui/src/chatwidget.rs | 82 +++++--- codex-rs/tui/src/git_action_directives.rs | 197 +++++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/resume_picker.rs | 3 +- codex-rs/tui/src/resume_picker/transcript.rs | 9 +- 8 files changed, 300 insertions(+), 31 deletions(-) create mode 100644 codex-rs/tui/src/git_action_directives.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b2261f155a..c019419a46 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -329,6 +329,14 @@ impl App { AppEvent::AppendMessageHistoryEntry { thread_id, text } => { self.append_message_history_entry(thread_id, text); } + AppEvent::SyncThreadGitBranch { thread_id, branch } => { + if let Err(err) = app_server + .thread_metadata_update_branch(thread_id, branch) + .await + { + tracing::warn!("failed to sync thread git branch from directive: {err}"); + } + } AppEvent::LookupMessageHistoryEntry { thread_id, offset, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b72e909293..5b54e08556 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -154,6 +154,12 @@ pub(crate) enum AppEvent { text: String, }, + /// Persist a branch discovered from an App git-action directive into thread metadata. + SyncThreadGitBranch { + thread_id: ThreadId, + branch: String, + }, + /// Fetch a persistent cross-session message history entry by offset. LookupMessageHistoryEntry { thread_id: ThreadId, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9f1e0f8101..8bc6140c5a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -69,6 +69,9 @@ use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadMemoryModeSetParams; use codex_app_server_protocol::ThreadMemoryModeSetResponse; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; @@ -494,6 +497,28 @@ impl AppServerSession { Ok(response.thread) } + pub(crate) async fn thread_metadata_update_branch( + &mut self, + thread_id: ThreadId, + branch: String, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadMetadataUpdate { + request_id, + params: ThreadMetadataUpdateParams { + thread_id: thread_id.to_string(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some(branch)), + origin_url: None, + }), + }, + }) + .await + .wrap_err("thread/metadata/update failed while syncing git branch") + } + pub(crate) async fn thread_inject_items( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85a9c3a8f3..bc6d35496f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -58,6 +58,7 @@ use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; use crate::diff_model::FileChange; +use crate::git_action_directives::parse_assistant_markdown; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; @@ -1748,6 +1749,7 @@ impl ChatWidget { // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell // that can re-render from source on resize. if let Some(source) = source { + let source = parse_assistant_markdown(&source).visible_markdown; self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), @@ -2487,7 +2489,10 @@ impl ChatWidget { // source only when no earlier item-level event (AgentMessageItem, plan // commit, review output) already recorded markdown for this turn. This // prevents the final summary from overwriting a more specific source. - if let Some(message) = last_agent_message + let sanitized_last_agent_message = last_agent_message + .as_deref() + .map(|message| parse_assistant_markdown(message).visible_markdown); + if let Some(message) = sanitized_last_agent_message .as_ref() .filter(|message| !message.is_empty()) && !self.saw_copy_source_this_turn @@ -2496,7 +2501,7 @@ impl ChatWidget { } // For desktop notifications: prefer the notification payload, fall back to // the item-level copy source if present, otherwise send an empty string. - let notification_response = last_agent_message + let notification_response = sanitized_last_agent_message .as_ref() .filter(|message| !message.is_empty()) .cloned() @@ -4228,18 +4233,36 @@ impl ChatWidget { /// Commentary completion sets a deferred restore flag so the status row /// returns once stream queues are idle. Final-answer completion (or absent /// phase for legacy models) clears the flag to preserve historical behavior. - fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) { + fn on_agent_message_item_completed(&mut self, item: AgentMessageItem, from_replay: bool) { let mut message = String::new(); for content in &item.content { match content { AgentMessageContent::Text { text } => message.push_str(text), } } + let parsed = parse_assistant_markdown(&message); self.finalize_completed_assistant_message( - (!message.is_empty()).then_some(message.as_str()), + (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), ); - if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) && !message.is_empty() { - self.record_agent_markdown(&message); + if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) + && !parsed.visible_markdown.is_empty() + { + self.record_agent_markdown(&parsed.visible_markdown); + } + if !from_replay + && let Some(cwd) = parsed.last_created_branch_cwd() + && let Some(thread_id) = self.thread_id + && let Some(runner) = self.workspace_command_runner.clone() + { + let cwd = PathBuf::from(cwd); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + if let Some(branch) = + crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await + { + tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch }); + } + }); } self.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. @@ -6067,28 +6090,31 @@ impl ChatWidget { phase, memory_citation, } => { - self.on_agent_message_item_completed(AgentMessageItem { - id, - content: vec![AgentMessageContent::Text { text }], - phase, - memory_citation: memory_citation.map(|citation| { - codex_protocol::memory_citation::MemoryCitation { - entries: citation - .entries - .into_iter() - .map( - |entry| codex_protocol::memory_citation::MemoryCitationEntry { - path: entry.path, - line_start: entry.line_start, - line_end: entry.line_end, - note: entry.note, - }, - ) - .collect(), - rollout_ids: citation.thread_ids, - } - }), - }); + self.on_agent_message_item_completed( + AgentMessageItem { + id, + content: vec![AgentMessageContent::Text { text }], + phase, + memory_citation: memory_citation.map(|citation| { + codex_protocol::memory_citation::MemoryCitation { + entries: citation + .entries + .into_iter() + .map(|entry| { + codex_protocol::memory_citation::MemoryCitationEntry { + path: entry.path, + line_start: entry.line_start, + line_end: entry.line_end, + note: entry.note, + } + }) + .collect(), + rollout_ids: citation.thread_ids, + } + }), + }, + from_replay, + ); } ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), ThreadItem::Reasoning { diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs new file mode 100644 index 0000000000..4dda4d451c --- /dev/null +++ b/codex-rs/tui/src/git_action_directives.rs @@ -0,0 +1,197 @@ +//! Codex App git action directives embedded in assistant markdown. + +use std::collections::HashSet; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum GitActionDirective { + Stage { + cwd: String, + }, + Commit { + cwd: String, + }, + CreateBranch { + cwd: String, + branch: String, + }, + Push { + cwd: String, + branch: String, + }, + CreatePr { + cwd: String, + branch: String, + url: Option, + is_draft: bool, + }, +} + +impl GitActionDirective { + pub(crate) fn created_branch_cwd(&self) -> Option<&str> { + match self { + Self::CreateBranch { cwd, .. } => Some(cwd), + _ => None, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct ParsedAssistantMarkdown { + pub(crate) visible_markdown: String, + pub(crate) git_actions: Vec, +} + +impl ParsedAssistantMarkdown { + pub(crate) fn last_created_branch_cwd(&self) -> Option<&str> { + self.git_actions + .iter() + .rev() + .find_map(GitActionDirective::created_branch_cwd) + } +} + +pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdown { + let mut git_actions = Vec::new(); + let mut seen = HashSet::new(); + let mut visible_lines = Vec::new(); + + for line in markdown.lines() { + let (visible_line, line_actions) = strip_line_directives(line); + for action in line_actions { + if seen.insert(action.clone()) { + git_actions.push(action); + } + } + visible_lines.push(visible_line.trim_end().to_string()); + } + + while visible_lines + .last() + .is_some_and(std::string::String::is_empty) + { + visible_lines.pop(); + } + + ParsedAssistantMarkdown { + visible_markdown: visible_lines.join("\n"), + git_actions, + } +} + +fn strip_line_directives(line: &str) -> (String, Vec) { + let mut visible = String::new(); + let mut actions = Vec::new(); + let mut remaining = line; + + while let Some(start) = remaining.find("::git-") { + visible.push_str(&remaining[..start]); + let directive = &remaining[start + 2..]; + let Some(open_brace) = directive.find('{') else { + visible.push_str(&remaining[start..]); + return (visible, actions); + }; + let Some(close_brace) = directive[open_brace + 1..].find('}') else { + visible.push_str(&remaining[start..]); + return (visible, actions); + }; + let close_brace = open_brace + 1 + close_brace; + let name = &directive[..open_brace]; + let attributes = &directive[open_brace + 1..close_brace]; + if let Some(action) = parse_git_action(name, attributes) { + actions.push(action); + } + remaining = &directive[close_brace + 1..]; + } + visible.push_str(remaining); + (visible, actions) +} + +fn parse_git_action(name: &str, attributes: &str) -> Option { + let attrs = parse_attributes(attributes)?; + let cwd = attrs.get("cwd")?.clone(); + match name { + "git-stage" => Some(GitActionDirective::Stage { cwd }), + "git-commit" => Some(GitActionDirective::Commit { cwd }), + "git-create-branch" => Some(GitActionDirective::CreateBranch { + cwd, + branch: attrs.get("branch")?.clone(), + }), + "git-push" => Some(GitActionDirective::Push { + cwd, + branch: attrs.get("branch")?.clone(), + }), + "git-create-pr" => Some(GitActionDirective::CreatePr { + cwd, + branch: attrs.get("branch")?.clone(), + url: attrs.get("url").cloned(), + is_draft: attrs.get("isDraft").is_some_and(|value| value == "true"), + }), + _ => None, + } +} + +fn parse_attributes(input: &str) -> Option> { + let mut attrs = std::collections::HashMap::new(); + let mut rest = input.trim(); + while !rest.is_empty() { + let eq = rest.find('=')?; + let key = rest[..eq].trim(); + if key.is_empty() { + return None; + } + rest = rest[eq + 1..].trim_start(); + let (value, next) = if let Some(quoted) = rest.strip_prefix('"') { + let end = quoted.find('"')?; + (quoted[..end].to_string(), "ed[end + 1..]) + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + (rest[..end].to_string(), &rest[end..]) + }; + attrs.insert(key.to_string(), value); + rest = next.trim_start(); + } + Some(attrs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_and_parses_git_action_directives() { + let parsed = parse_assistant_markdown( + "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"}", + ); + + assert_eq!(parsed.visible_markdown, "Done"); + assert_eq!( + parsed.git_actions, + vec![ + GitActionDirective::Stage { + cwd: "/repo".to_string(), + }, + GitActionDirective::Push { + cwd: "/repo".to_string(), + branch: "feat/x".to_string(), + }, + ] + ); + } + + #[test] + fn hides_malformed_directives_without_materializing_rows() { + let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}"); + + assert_eq!(parsed.visible_markdown, "Done"); + assert!(parsed.git_actions.is_empty()); + } + + #[test] + fn last_created_branch_cwd_uses_the_last_matching_directive() { + let parsed = parse_assistant_markdown( + "::git-create-branch{cwd=\"/first\" branch=\"first\"}\n::git-push{cwd=\"/repo\" branch=\"first\"}\n::git-create-branch{cwd=\"/second\" branch=\"second\"}", + ); + + assert_eq!(parsed.last_created_branch_cwd(), Some("/second")); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ac5b489af1..0e585007b0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -124,6 +124,7 @@ mod external_editor; mod file_search; mod frames; mod get_git_diff; +mod git_action_directives; mod goal_display; mod history_cell; mod ide_context; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 66e26d9778..be89655d7c 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -9,6 +9,7 @@ mod transcript; use crate::app_server_session::AppServerSession; use crate::color::blend; use crate::color::is_light; +use crate::git_action_directives::parse_assistant_markdown; use crate::keymap::PagerKeymap; use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; @@ -780,7 +781,7 @@ async fn load_transcript_preview( }), ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { speaker: TranscriptPreviewSpeaker::Assistant, - text: text.clone(), + text: parse_assistant_markdown(text).visible_markdown, }), _ => None, }) diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs index 4fe75efe63..5dbc7707c2 100644 --- a/codex-rs/tui/src/resume_picker/transcript.rs +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use crate::app_server_session::AppServerSession; +use crate::git_action_directives::parse_assistant_markdown; use crate::history_cell::AgentMarkdownCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; @@ -61,8 +62,12 @@ pub(crate) fn thread_to_transcript_cells( })); } ThreadItem::AgentMessage { text, .. } => { - if !text.trim().is_empty() { - cells.push(Arc::new(AgentMarkdownCell::new(text.clone(), cwd))); + let parsed = parse_assistant_markdown(text); + if !parsed.visible_markdown.trim().is_empty() { + cells.push(Arc::new(AgentMarkdownCell::new( + parsed.visible_markdown, + cwd, + ))); } } ThreadItem::Plan { text, .. } => {