tui: hide app git directives in transcripts

This commit is contained in:
Felipe Coury
2026-05-09 13:39:04 -03:00
parent 0c70698e24
commit ea5565d644
8 changed files with 300 additions and 31 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View 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(), &quoted[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"));
}
}

View File

@@ -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;

View File

@@ -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,
})

View File

@@ -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, .. } => {