mirror of
https://github.com/openai/codex.git
synced 2026-05-18 02:02:30 +00:00
tui: hide app git directives in transcripts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ThreadMetadataUpdateResponse> {
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
197
codex-rs/tui/src/git_action_directives.rs
Normal file
197
codex-rs/tui/src/git_action_directives.rs
Normal file
@@ -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<String>,
|
||||
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<GitActionDirective>,
|
||||
}
|
||||
|
||||
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<GitActionDirective>) {
|
||||
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<GitActionDirective> {
|
||||
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<std::collections::HashMap<String, String>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
Reference in New Issue
Block a user