Files
codex/codex-rs/tui/src/goal_files.rs
T
Eric Traut 9e07892253 [3 of 3] Support images in TUI goals (#27510)
## Stack

1. [1 of 3] Support long raw TUI goal objectives - #27508
2. [2 of 3] Support long pasted text in TUI goals - #27509
3. **[3 of 3] Support images in TUI goals** - this PR

## Why

The first two PRs make goal definitions resilient to long text, but
`/goal` still dropped image inputs from the composer. That meant a user
could attach images while defining a goal and the resulting goal
continuation would not have any useful reference to those images.

Goal state still persists only objective text, so image inputs need to
become paths or URLs that the agent can read later.

## What Changed

- Extends TUI `GoalDraft` with local image attachments and remote image
URLs.
- Copies local goal images through the app-server filesystem layer into
the managed goal attachment directory, then rewrites active image
placeholders to file references.
- Appends unplaced local images and remote image URLs to the objective
as referenced image files or URLs.
- Preserves goal image metadata through live `/goal` submission and
queued `/goal` dispatch.

## Verification

- Added goal materialization coverage for local image files and remote
image URLs.
- Added/updated TUI slash-command coverage showing `/goal` drafts
include attached images instead of dropping them.

## Manual Testing

- Attached an image by bracketed-pasting its local path into a live
`/goal` composer. The `[Image #1]` placeholder became a server-host
`image-1.png` reference, copied bytes matched exactly, and no attachment
was written under the TUI's local home.
- Deleted an image placeholder before submitting a small goal and
verified no image was copied.
- Attached PNG and JPEG files to the same goal. Placeholder order was
preserved as `image-1.png` and `image-2.jpg`, and both remote copies
matched their source bytes.
- Tried extensionless, malformed-extension, and
extension/content-mismatched paths; the composer rejected them as image
attachments before goal dispatch rather than creating misleading managed
image files.
- Combined a local image, a large pasted block, and enough raw text to
exceed 4,000 characters. The remote attachment directory contained the
image, paste sidecar, and `goal-objective.md`; all embedded references
used server-host paths and both payloads matched their sources.
- Submitted an image replacement while a goal was active, verified no
image was copied before confirmation, then canceled and confirmed the
attachment count was unchanged.
2026-06-12 17:14:27 -07:00

243 lines
8.4 KiB
Rust

//! Materializes oversized TUI goal objectives, pastes, and images as app-server-host files.
use std::fs;
use std::path::Path;
use crate::app_server_session::AppServerSession;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::LocalImageAttachment;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_app_server_client::AppServerPath;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use codex_protocol::user_input::TextElement;
use uuid::Uuid;
const GOAL_ATTACHMENT_DIR: &str = "attachments";
const GOAL_FILE_PREFIX: &str = "Read the Codex goal objective file at ";
const GOAL_FILE_SUFFIX: &str = " before continuing.";
const GOAL_FILE_NAME: &str = "goal-objective.md";
#[derive(Clone, Debug, Default)]
pub(crate) struct GoalDraft {
pub(crate) objective: String,
pub(crate) text_elements: Vec<TextElement>,
pub(crate) pending_pastes: Vec<(String, String)>,
pub(crate) local_images: Vec<LocalImageAttachment>,
pub(crate) remote_image_urls: Vec<String>,
}
pub(crate) type GoalFilePath = AppServerPath;
pub(crate) async fn materialize_goal_draft(
app_server: &mut AppServerSession,
codex_home: Option<&GoalFilePath>,
draft: GoalDraft,
) -> Result<(String, Option<GoalFilePath>)> {
let mut objective = draft.objective;
if objective.trim().is_empty() {
bail!("Goal objective must not be empty.");
}
let text_elements = draft.text_elements;
if !draft.pending_pastes.is_empty() {
let (expanded_objective, _) = ChatComposer::expand_pending_pastes(
&objective,
text_elements.clone(),
&draft.pending_pastes,
);
if expanded_objective.trim().is_empty() {
bail!("Goal objective must not be empty.");
}
}
let mut active_placeholders = text_elements
.iter()
.filter_map(|element| element.placeholder(&objective))
.filter(|placeholder| !placeholder.is_empty())
.collect::<Vec<_>>();
let mut output_dir = None;
let mut replacements = Vec::new();
for (placeholder, text) in draft.pending_pastes.iter() {
let Some(active_idx) = active_placeholders
.iter()
.position(|active| *active == placeholder.as_str())
else {
continue;
};
active_placeholders.swap_remove(active_idx);
let path = ensure_goal_output_dir(app_server, codex_home, &mut output_dir)
.await?
.join(format!("pasted-text-{}.txt", replacements.len() + 1));
write_goal_file(app_server, path.clone(), text.as_bytes().to_vec()).await?;
replacements.push((
placeholder.clone(),
format!("pasted text file: {path}. Read this file before continuing."),
));
}
let mut image_lines = Vec::new();
for (idx, image) in draft.local_images.iter().enumerate() {
if !image.placeholder.is_empty() {
let Some(active_idx) = active_placeholders
.iter()
.position(|active| *active == image.placeholder.as_str())
else {
continue;
};
active_placeholders.swap_remove(active_idx);
}
let extension = image_extension(&image.path);
let path = ensure_goal_output_dir(app_server, codex_home, &mut output_dir)
.await?
.join(format!("image-{}.{}", idx + 1, extension));
let bytes = fs::read(&image.path)
.with_context(|| format!("Could not read goal image {}", image.path.display()))?;
write_goal_file(app_server, path.clone(), bytes).await?;
if image.placeholder.is_empty() {
image_lines.push(format!("- [Image #{}]: {path}", idx + 1));
} else {
replacements.push((image.placeholder.clone(), format!("image file: {path}")));
}
}
let (expanded_objective, _) =
ChatComposer::expand_pending_pastes(&objective, text_elements, &replacements);
objective = expanded_objective.trim().to_string();
append_section(&mut objective, "Referenced image files:", image_lines);
append_section(
&mut objective,
"Referenced image URLs:",
draft
.remote_image_urls
.into_iter()
.enumerate()
.map(|(idx, url)| format!("- [Image #{}]: {url}", idx + 1))
.collect(),
);
if objective.chars().count() > MAX_THREAD_GOAL_OBJECTIVE_CHARS {
let path = ensure_goal_output_dir(app_server, codex_home, &mut output_dir)
.await?
.join(GOAL_FILE_NAME);
let reference = match objective_file_reference(&path) {
Ok(reference) => reference,
Err(err) => {
if let Some(output_dir) = output_dir.as_ref() {
let _ = app_server.fs_remove_path(output_dir).await;
}
return Err(err);
}
};
write_goal_file(app_server, path.clone(), objective.as_bytes().to_vec()).await?;
objective = reference;
}
Ok((objective, output_dir))
}
pub(crate) async fn objective_text_for_edit(
app_server: &mut AppServerSession,
codex_home: Option<&GoalFilePath>,
objective: &str,
) -> Result<String> {
let Some(path) = objective_file_path(objective, codex_home) else {
return Ok(objective.to_string());
};
let bytes = app_server
.fs_read_file_path(&path)
.await
.map_err(|err| anyhow::anyhow!("{err}"))
.with_context(|| format!("Could not read goal objective file {path}"))?;
String::from_utf8(bytes)
.with_context(|| format!("Goal objective file {path} is not valid UTF-8"))
}
pub(crate) fn objective_file_path(
objective: &str,
codex_home: Option<&GoalFilePath>,
) -> Option<GoalFilePath> {
let path = objective
.strip_prefix(GOAL_FILE_PREFIX)
.and_then(|path| path.strip_suffix(GOAL_FILE_SUFFIX))?;
let path = AppServerPath::from_absolute_str(path)?;
let parts = path.components();
let attachment_id = parts.get(parts.len().checked_sub(2)?)?;
let expected = codex_home?
.join(GOAL_ATTACHMENT_DIR)
.join(attachment_id)
.join(GOAL_FILE_NAME);
(path == expected && Uuid::parse_str(attachment_id).is_ok()).then_some(path)
}
pub(crate) fn objective_file_reference(path: &GoalFilePath) -> Result<String> {
let reference = format!("{GOAL_FILE_PREFIX}{path}{GOAL_FILE_SUFFIX}");
let actual_chars = reference.chars().count();
if actual_chars > MAX_THREAD_GOAL_OBJECTIVE_CHARS {
bail!(
"Goal objective file reference is too long: {actual_chars} characters. Limit: {MAX_THREAD_GOAL_OBJECTIVE_CHARS} characters."
);
}
Ok(reference)
}
async fn ensure_goal_output_dir(
app_server: &mut AppServerSession,
codex_home: Option<&GoalFilePath>,
output_dir: &mut Option<GoalFilePath>,
) -> Result<GoalFilePath> {
if let Some(output_dir) = output_dir {
return Ok(output_dir.clone());
}
let codex_home = codex_home
.context("App server did not report $CODEX_HOME; cannot materialize goal files")?;
let path = codex_home
.join(GOAL_ATTACHMENT_DIR)
.join(Uuid::new_v4().to_string());
app_server
.fs_create_directory_all_path(&path)
.await
.map_err(|err| anyhow::anyhow!("{err}"))
.with_context(|| format!("Could not create goal attachment directory {path}"))?;
*output_dir = Some(path.clone());
Ok(path)
}
async fn write_goal_file(
app_server: &mut AppServerSession,
path: GoalFilePath,
bytes: Vec<u8>,
) -> Result<()> {
app_server
.fs_write_file_path(&path, bytes)
.await
.map_err(|err| anyhow::anyhow!("{err}"))
.with_context(|| format!("Could not write goal file {path}"))
}
fn append_section(objective: &mut String, heading: &str, lines: Vec<String>) {
if lines.is_empty() {
return;
}
if !objective.ends_with('\n') {
objective.push_str("\n\n");
}
objective.push_str(heading);
objective.push('\n');
objective.push_str(&lines.join("\n"));
}
fn image_extension(path: &Path) -> String {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
extension
.chars()
.filter(char::is_ascii_alphanumeric)
.take(8)
.collect::<String>()
})
.filter(|extension| !extension.is_empty())
.unwrap_or_else(|| "png".to_string())
}