Files
codex/codex-rs/tui/src/chatwidget/goal_menu.rs
T
Eric Traut c0f1ec5afd [2 of 3] Support long pasted text in TUI goals (#27509)
## Stack

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

## Why

Large text pasted into the TUI composer is represented as a paste
placeholder plus pending paste metadata. For `/goal`, preserving only
the visible placeholder is not enough: the agent would see a short
placeholder string instead of the actual pasted text, and the long-text
support from the first PR would never see the payload.

The TUI also needs to avoid writing stale sidecar files when a user
pastes a large block and then deletes its placeholder before submitting
the goal.

## What Changed

- Introduces a TUI `GoalDraft` for goal submissions so `/goal`, `/goal
edit`, and queued goal commands can carry objective text plus text
elements and pending paste payloads.
- Materializes active pasted-text placeholders to `pasted-text-N.txt`
files through the app-server filesystem path introduced in #27508.
- Rewrites active paste placeholders in the persisted objective to file
references, while leaving literal placeholder-looking text alone.
- Filters out deleted paste placeholders so otherwise-small goals do not
require `$CODEX_HOME` or remote filesystem writes.
- Preserves pending paste metadata when a `/goal` command is queued
before a thread exists.

## Verification

- Added goal materialization tests for active paste placeholders,
deleted paste placeholders, and whitespace-only paste payloads.
- Added/updated TUI slash-command tests for large pasted text, queued
`/goal` commands before thread start, and queued oversized goal
behavior.

## Manual Testing

- Used real terminal bracketed-paste sequences through a remote TUI
session. A 1,228-byte multiline paste became `pasted-text-1.txt`; its
first/last lines and byte count matched exactly, and the persisted
objective referenced the server-host path.
- Pasted a large block, deleted its placeholder, and submitted a small
replacement objective. No new directory or sidecar file was created.
- Added two same-length large pastes to one goal. The composer
disambiguated their visible placeholders, and materialization preserved
order and contents in `pasted-text-1.txt` and `pasted-text-2.txt`.
- Submitted a whitespace-only large paste and verified the goal was
rejected as empty without writing a file.
- Submitted a pasted-text replacement while another goal was active,
verified no file was written before confirmation, then canceled and
confirmed the original goal remained unchanged.
- Combined a large paste with enough raw text to exceed 4,000 characters
after placeholder rewriting. The paste sidecar and `goal-objective.md`
were created in the same remote attachment directory, and `/goal edit`
restored the rewritten objective with its sidecar reference.
2026-06-12 15:34:04 -07:00

144 lines
5.1 KiB
Rust

//! Goal summary for the bare `/goal` command.
use super::*;
use crate::goal_display::format_goal_elapsed_seconds;
use crate::goal_files;
use crate::status::format_tokens_compact;
impl ChatWidget {
pub(crate) fn show_goal_summary(&mut self, goal: AppThreadGoal) {
self.add_plain_history_lines(goal_summary_lines(&goal));
}
pub(crate) fn show_goal_edit_prompt(&mut self, thread_id: ThreadId, goal: AppThreadGoal) {
let tx = self.app_event_tx.clone();
let status = edited_goal_status(goal.status);
let token_budget = goal.token_budget;
let view = CustomPromptView::new(
"Edit goal".to_string(),
"Type a goal objective and press Enter".to_string(),
goal.objective,
/*context_label*/ None,
Box::new(move |objective: String| {
tx.send(AppEvent::SetThreadGoalDraft {
thread_id,
draft: goal_files::GoalDraft {
objective,
..Default::default()
},
mode: crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
});
}),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn show_resume_paused_goal_prompt(
&mut self,
thread_id: ThreadId,
objective: String,
) {
let resume_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::SetThreadGoalStatus {
thread_id,
status: AppThreadGoalStatus::Active,
});
})];
self.show_selection_view(SelectionViewParams {
title: Some("Resume paused goal?".to_string()),
subtitle: Some(format!("Goal: {objective}")),
footer_hint: Some(standard_popup_hint_line()),
initial_selected_idx: Some(0),
items: vec![
SelectionItem {
name: "Resume goal".to_string(),
description: Some("Mark it active and continue when idle".to_string()),
actions: resume_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Leave paused".to_string(),
description: Some("Keep it paused; use /goal resume later".to_string()),
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
});
}
pub(crate) fn on_thread_goal_cleared(&mut self, thread_id: &str) {
if self
.thread_id
.is_some_and(|active_thread_id| active_thread_id.to_string() == thread_id)
{
self.current_goal_status = None;
self.update_collaboration_mode_indicator();
}
}
}
fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from("Goal".bold()),
Line::from(vec![
"Status: ".dim(),
goal_status_label(goal.status).to_string().into(),
]),
Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]),
Line::from(vec![
"Time used: ".dim(),
format_goal_elapsed_seconds(goal.time_used_seconds).into(),
]),
Line::from(vec![
"Tokens used: ".dim(),
format_tokens_compact(goal.tokens_used).into(),
]),
];
if let Some(token_budget) = goal.token_budget {
lines.push(Line::from(vec![
"Token budget: ".dim(),
format_tokens_compact(token_budget).into(),
]));
}
let command_hint = match goal.status {
AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear",
AppThreadGoalStatus::Paused
| AppThreadGoalStatus::Blocked
| AppThreadGoalStatus::UsageLimited => "Commands: /goal edit, /goal resume, /goal clear",
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
"Commands: /goal edit, /goal clear"
}
};
lines.push(Line::default());
lines.push(Line::from(command_hint.dim()));
lines
}
fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
match status {
AppThreadGoalStatus::Active => "active",
AppThreadGoalStatus::Paused => "paused",
AppThreadGoalStatus::Blocked => "blocked",
AppThreadGoalStatus::UsageLimited => "usage limited",
AppThreadGoalStatus::BudgetLimited => "limited by budget",
AppThreadGoalStatus::Complete => "complete",
}
}
fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus {
match status {
AppThreadGoalStatus::Active => AppThreadGoalStatus::Active,
AppThreadGoalStatus::Paused
| AppThreadGoalStatus::Blocked
| AppThreadGoalStatus::UsageLimited => status,
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
AppThreadGoalStatus::Active
}
}
}