mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Refactor chatwidget state into modules (#22269)
## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. After #21866 consolidated some of the state it tracks, this starts the next phase by moving coherent state/helper clusters out of the main module without changing behavior. This PR is intentionally mechanical: it only moves existing functions, structs, and helpers into focused modules so the boundaries are easier to review before the less mechanical refactors that should follow. ## What Changed - Moved user-message, composer, queue, pending steer, and merge/remap helpers into `codex-rs/tui/src/chatwidget/user_messages.rs`. - Added `codex-rs/tui/src/chatwidget/exec_state.rs` for unified exec bookkeeping helpers. - Added `codex-rs/tui/src/chatwidget/rate_limits.rs` for rate-limit warning, prompt, and error classification state. - Moved plugin list fetch and install auth-flow state into `codex-rs/tui/src/chatwidget/plugins.rs`. - Made a couple of test-only `VecDeque` imports explicit now that those tests no longer inherit the parent module import. ## Verification - `cargo test -p codex-tui` was run ## Follow-On Refactor Phases This PR is phase 1: mechanical helper and state moves. Planned follow-up PRs: - Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. - Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. - Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. - Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer.
This commit is contained in:
committed by
GitHub
Unverified
parent
c51c65ad09
commit
92930a8d40
+38
-680
@@ -32,8 +32,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -113,7 +111,6 @@ use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::TextElement as AppServerTextElement;
|
||||
use codex_app_server_protocol::ThreadGoal as AppThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
@@ -158,8 +155,6 @@ use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
@@ -324,6 +319,14 @@ use crate::tui::FrameRequester;
|
||||
mod connectors;
|
||||
use self::connectors::ConnectorsCacheState;
|
||||
use self::connectors::ConnectorsState;
|
||||
mod exec_state;
|
||||
use self::exec_state::RunningCommand;
|
||||
use self::exec_state::UnifiedExecProcessSummary;
|
||||
use self::exec_state::UnifiedExecWaitState;
|
||||
use self::exec_state::UnifiedExecWaitStreak;
|
||||
use self::exec_state::command_execution_command_and_parsed;
|
||||
use self::exec_state::is_standard_tool_call;
|
||||
use self::exec_state::is_unified_exec_source;
|
||||
mod goal_status;
|
||||
use self::goal_status::GoalStatusState;
|
||||
#[cfg(test)]
|
||||
@@ -349,10 +352,21 @@ use self::skills::collect_tool_mentions;
|
||||
use self::skills::find_app_mentions;
|
||||
use self::skills::find_skill_mentions_with_tool_mentions;
|
||||
mod plugins;
|
||||
use self::plugins::PluginInstallAuthFlowState;
|
||||
use self::plugins::PluginListFetchState;
|
||||
use self::plugins::PluginsCacheState;
|
||||
mod plan_implementation;
|
||||
use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
|
||||
mod protocol;
|
||||
mod rate_limits;
|
||||
use self::rate_limits::NUDGE_MODEL_SLUG;
|
||||
use self::rate_limits::RATE_LIMIT_SWITCH_PROMPT_THRESHOLD;
|
||||
use self::rate_limits::RateLimitErrorKind;
|
||||
use self::rate_limits::RateLimitSwitchPromptState;
|
||||
use self::rate_limits::RateLimitWarningState;
|
||||
use self::rate_limits::app_server_rate_limit_error_kind;
|
||||
pub(crate) use self::rate_limits::get_limits_duration;
|
||||
use self::rate_limits::is_app_server_cyber_policy_error;
|
||||
mod realtime;
|
||||
use self::realtime::RealtimeConversationUiState;
|
||||
mod reasoning_shortcuts;
|
||||
@@ -371,8 +385,27 @@ use self::transcript::TranscriptState;
|
||||
mod turn_lifecycle;
|
||||
use self::turn_lifecycle::TurnLifecycleState;
|
||||
mod user_messages;
|
||||
use self::user_messages::PendingSteer;
|
||||
use self::user_messages::PendingSteerCompareKey;
|
||||
use self::user_messages::QueueDrain;
|
||||
use self::user_messages::QueuedUserMessage;
|
||||
use self::user_messages::ShellEscapePolicy;
|
||||
use self::user_messages::ThreadComposerState;
|
||||
pub(crate) use self::user_messages::ThreadInputState;
|
||||
pub(crate) use self::user_messages::UserMessage;
|
||||
use self::user_messages::UserMessageDisplay;
|
||||
#[cfg(test)]
|
||||
use self::user_messages::UserMessageHistoryOverride;
|
||||
use self::user_messages::UserMessageHistoryRecord;
|
||||
use self::user_messages::app_server_text_elements;
|
||||
pub(crate) use self::user_messages::create_initial_user_message;
|
||||
use self::user_messages::merge_user_messages;
|
||||
use self::user_messages::merge_user_messages_with_history_record;
|
||||
#[cfg(test)]
|
||||
use self::user_messages::remap_placeholders_for_message;
|
||||
use self::user_messages::user_message_display_for_history;
|
||||
use self::user_messages::user_message_for_restore;
|
||||
use self::user_messages::user_message_preview_text;
|
||||
mod warnings;
|
||||
use self::warnings::WarningDisplayState;
|
||||
pub(crate) use crate::branch_summary::StatusLineGitSummary;
|
||||
@@ -401,176 +434,8 @@ const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it l
|
||||
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"];
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
source: ExecCommandSource,
|
||||
}
|
||||
|
||||
struct UnifiedExecProcessSummary {
|
||||
key: String,
|
||||
call_id: String,
|
||||
command_display: String,
|
||||
recent_chunks: Vec<String>,
|
||||
}
|
||||
|
||||
struct UnifiedExecWaitState {
|
||||
command_display: String,
|
||||
}
|
||||
|
||||
impl UnifiedExecWaitState {
|
||||
fn new(command_display: String) -> Self {
|
||||
Self { command_display }
|
||||
}
|
||||
|
||||
fn is_duplicate(&self, command_display: &str) -> bool {
|
||||
self.command_display == command_display
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct UnifiedExecWaitStreak {
|
||||
process_id: String,
|
||||
command_display: Option<String>,
|
||||
}
|
||||
|
||||
impl UnifiedExecWaitStreak {
|
||||
fn new(process_id: String, command_display: Option<String>) -> Self {
|
||||
Self {
|
||||
process_id,
|
||||
command_display: command_display.filter(|display| !display.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_command_display(&mut self, command_display: Option<String>) {
|
||||
if self.command_display.is_some() {
|
||||
return;
|
||||
}
|
||||
self.command_display = command_display.filter(|display| !display.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unified_exec_source(source: ExecCommandSource) -> bool {
|
||||
matches!(
|
||||
source,
|
||||
ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction
|
||||
)
|
||||
}
|
||||
|
||||
fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
|
||||
!parsed_cmd.is_empty()
|
||||
&& parsed_cmd
|
||||
.iter()
|
||||
.all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. }))
|
||||
}
|
||||
|
||||
fn command_execution_command_and_parsed(
|
||||
command: &str,
|
||||
command_actions: &[codex_app_server_protocol::CommandAction],
|
||||
) -> (Vec<String>, Vec<ParsedCommand>) {
|
||||
(
|
||||
split_command_string(command),
|
||||
command_actions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(codex_app_server_protocol::CommandAction::into_core)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
||||
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
const MAX_AGENT_COPY_HISTORY: usize = 32;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateLimitWarningState {
|
||||
secondary_index: usize,
|
||||
primary_index: usize,
|
||||
}
|
||||
|
||||
impl RateLimitWarningState {
|
||||
fn take_warnings(
|
||||
&mut self,
|
||||
secondary_used_percent: Option<f64>,
|
||||
secondary_window_minutes: Option<i64>,
|
||||
primary_used_percent: Option<f64>,
|
||||
primary_window_minutes: Option<i64>,
|
||||
) -> Vec<String> {
|
||||
let reached_secondary_cap =
|
||||
matches!(secondary_used_percent, Some(percent) if percent == 100.0);
|
||||
let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);
|
||||
if reached_secondary_cap || reached_primary_cap {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if let Some(secondary_used_percent) = secondary_used_percent {
|
||||
let mut highest_secondary: Option<f64> = None;
|
||||
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
||||
{
|
||||
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
||||
self.secondary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_secondary {
|
||||
let limit_label = secondary_window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string());
|
||||
let remaining_percent = 100.0 - threshold;
|
||||
warnings.push(format!(
|
||||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(primary_used_percent) = primary_used_percent {
|
||||
let mut highest_primary: Option<f64> = None;
|
||||
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
||||
{
|
||||
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
||||
self.primary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_primary {
|
||||
let limit_label = primary_window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string());
|
||||
let remaining_percent = 100.0 - threshold;
|
||||
warnings.push(format!(
|
||||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
|
||||
const MINUTES_PER_HOUR: i64 = 60;
|
||||
const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;
|
||||
const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY;
|
||||
const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY;
|
||||
const ROUNDING_BIAS_MINUTES: i64 = 3;
|
||||
|
||||
let windows_minutes = windows_minutes.max(0);
|
||||
|
||||
if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES);
|
||||
let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR);
|
||||
format!("{hours}h")
|
||||
} else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
"weekly".to_string()
|
||||
} else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
"monthly".to_string()
|
||||
} else {
|
||||
"annual".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Common initialization parameters shared by all `ChatWidget` constructors.
|
||||
pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) config: Config,
|
||||
@@ -600,48 +465,6 @@ pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum RateLimitSwitchPromptState {
|
||||
#[default]
|
||||
Idle,
|
||||
Pending,
|
||||
Shown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct PluginListFetchState {
|
||||
cache_cwd: Option<PathBuf>,
|
||||
in_flight_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PluginInstallAuthFlowState {
|
||||
plugin_display_name: String,
|
||||
next_app_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RateLimitErrorKind {
|
||||
ServerOverloaded,
|
||||
UsageLimit,
|
||||
Generic,
|
||||
}
|
||||
|
||||
fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option<RateLimitErrorKind> {
|
||||
match info {
|
||||
AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
|
||||
AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
|
||||
AppServerCodexErrorInfo::ResponseTooManyFailedAttempts {
|
||||
http_status_code: Some(429),
|
||||
} => Some(RateLimitErrorKind::Generic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
|
||||
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum ExternalEditorState {
|
||||
#[default]
|
||||
@@ -890,148 +713,6 @@ pub(crate) struct ActiveCellTranscriptKey {
|
||||
pub(crate) animation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct UserMessage {
|
||||
text: String,
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
/// Remote image attachments represented as URLs (for example data URLs)
|
||||
/// provided by app-server clients.
|
||||
///
|
||||
/// Unlike `local_images`, these are not created by TUI image attach/paste
|
||||
/// flows. The TUI can restore and remove them while editing/backtracking.
|
||||
remote_image_urls: Vec<String>,
|
||||
text_elements: Vec<TextElement>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum UserMessageHistoryRecord {
|
||||
UserMessageText,
|
||||
Override(UserMessageHistoryOverride),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct UserMessageHistoryOverride {
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShellEscapePolicy {
|
||||
Allow,
|
||||
Disallow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct QueuedUserMessage {
|
||||
user_message: UserMessage,
|
||||
action: QueuedInputAction,
|
||||
}
|
||||
|
||||
impl QueuedUserMessage {
|
||||
fn new(user_message: UserMessage, action: QueuedInputAction) -> Self {
|
||||
Self {
|
||||
user_message,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_user_message(self) -> UserMessage {
|
||||
self.user_message
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserMessage> for QueuedUserMessage {
|
||||
fn from(user_message: UserMessage) -> Self {
|
||||
Self::new(user_message, QueuedInputAction::Plain)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for QueuedUserMessage {
|
||||
type Target = UserMessage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.user_message
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum QueueDrain {
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
struct ThreadComposerState {
|
||||
text: String,
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
remote_image_urls: Vec<String>,
|
||||
text_elements: Vec<TextElement>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl ThreadComposerState {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.text.is_empty()
|
||||
|| !self.local_images.is_empty()
|
||||
|| !self.remote_image_urls.is_empty()
|
||||
|| !self.text_elements.is_empty()
|
||||
|| !self.mention_bindings.is_empty()
|
||||
|| !self.pending_pastes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ThreadInputState {
|
||||
composer: Option<ThreadComposerState>,
|
||||
pending_steers: VecDeque<UserMessage>,
|
||||
pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
|
||||
rejected_steers_queue: VecDeque<UserMessage>,
|
||||
rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
queued_user_messages: VecDeque<QueuedUserMessage>,
|
||||
queued_user_message_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
user_turn_pending_start: bool,
|
||||
current_collaboration_mode: CollaborationMode,
|
||||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
task_running: bool,
|
||||
agent_turn_running: bool,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for UserMessage {
|
||||
fn from(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingSteer {
|
||||
user_message: UserMessage,
|
||||
history_record: UserMessageHistoryRecord,
|
||||
compare_key: PendingSteerCompareKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum InterruptedTurnNoticeMode {
|
||||
#[default]
|
||||
@@ -1039,329 +720,6 @@ pub(crate) enum InterruptedTurnNoticeMode {
|
||||
Suppress,
|
||||
}
|
||||
|
||||
pub(crate) fn create_initial_user_message(
|
||||
text: Option<String>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
text_elements: Vec<TextElement>,
|
||||
) -> Option<UserMessage> {
|
||||
let text = text.unwrap_or_default();
|
||||
if text.is_empty() && local_image_paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let local_images = local_image_paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, path)| LocalImageAttachment {
|
||||
placeholder: local_image_label_text(idx + 1),
|
||||
path,
|
||||
})
|
||||
.collect();
|
||||
Some(UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements,
|
||||
mention_bindings: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn append_text_with_rebased_elements(
|
||||
target_text: &mut String,
|
||||
target_text_elements: &mut Vec<TextElement>,
|
||||
text: &str,
|
||||
text_elements: impl IntoIterator<Item = TextElement>,
|
||||
) {
|
||||
let offset = target_text.len();
|
||||
target_text.push_str(text);
|
||||
target_text_elements.extend(text_elements.into_iter().map(|mut element| {
|
||||
element.byte_range.start += offset;
|
||||
element.byte_range.end += offset;
|
||||
element
|
||||
}));
|
||||
}
|
||||
|
||||
fn app_server_text_elements(elements: &[TextElement]) -> Vec<AppServerTextElement> {
|
||||
elements.iter().cloned().map(Into::into).collect()
|
||||
}
|
||||
|
||||
fn build_placeholder_mapping(
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
next_label: &mut usize,
|
||||
) -> (HashMap<String, String>, Vec<LocalImageAttachment>) {
|
||||
let mut mapping: HashMap<String, String> = HashMap::new();
|
||||
let mut remapped_images = Vec::new();
|
||||
for attachment in local_images {
|
||||
let new_placeholder = local_image_label_text(*next_label);
|
||||
*next_label += 1;
|
||||
mapping.insert(attachment.placeholder.clone(), new_placeholder.clone());
|
||||
remapped_images.push(LocalImageAttachment {
|
||||
placeholder: new_placeholder,
|
||||
path: attachment.path,
|
||||
});
|
||||
}
|
||||
(mapping, remapped_images)
|
||||
}
|
||||
|
||||
fn remap_placeholders_in_text(
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
mapping: &HashMap<String, String>,
|
||||
) -> (String, Vec<TextElement>) {
|
||||
if mapping.is_empty() {
|
||||
return (text, text_elements);
|
||||
}
|
||||
|
||||
let mut elements = text_elements;
|
||||
elements.sort_by_key(|elem| elem.byte_range.start);
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let mut rebuilt = String::new();
|
||||
let mut rebuilt_elements = Vec::new();
|
||||
for mut elem in elements {
|
||||
let start = elem.byte_range.start.min(text.len());
|
||||
let end = elem.byte_range.end.min(text.len());
|
||||
if let Some(segment) = text.get(cursor..start) {
|
||||
rebuilt.push_str(segment);
|
||||
}
|
||||
|
||||
let original = text.get(start..end).unwrap_or("");
|
||||
let placeholder = elem.placeholder(&text);
|
||||
let replacement = placeholder
|
||||
.and_then(|ph| mapping.get(ph))
|
||||
.map(String::as_str)
|
||||
.unwrap_or(original);
|
||||
|
||||
let elem_start = rebuilt.len();
|
||||
rebuilt.push_str(replacement);
|
||||
let elem_end = rebuilt.len();
|
||||
|
||||
if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) {
|
||||
elem.set_placeholder(Some(remapped.clone()));
|
||||
}
|
||||
elem.byte_range = (elem_start..elem_end).into();
|
||||
rebuilt_elements.push(elem);
|
||||
cursor = end;
|
||||
}
|
||||
if let Some(segment) = text.get(cursor..) {
|
||||
rebuilt.push_str(segment);
|
||||
}
|
||||
|
||||
(rebuilt, rebuilt_elements)
|
||||
}
|
||||
|
||||
// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering
|
||||
// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so
|
||||
// the combined local_image_paths order matches the labels, even if placeholders were moved
|
||||
// in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to
|
||||
// history overrides so restored drafts and rendered transcript entries agree.
|
||||
fn remap_placeholders_for_message_and_history_record(
|
||||
message: UserMessage,
|
||||
history_record: UserMessageHistoryRecord,
|
||||
next_label: &mut usize,
|
||||
) -> (UserMessage, UserMessageHistoryRecord) {
|
||||
let UserMessage {
|
||||
text,
|
||||
text_elements,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
mention_bindings,
|
||||
} = message;
|
||||
let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label);
|
||||
let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping);
|
||||
let history_record = match history_record {
|
||||
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => {
|
||||
let (text, text_elements) =
|
||||
remap_placeholders_in_text(history.text, history.text_elements, &mapping);
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text,
|
||||
text_elements,
|
||||
})
|
||||
}
|
||||
record => record,
|
||||
};
|
||||
|
||||
(
|
||||
UserMessage {
|
||||
text,
|
||||
local_images: remapped_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings,
|
||||
},
|
||||
history_record,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage {
|
||||
remap_placeholders_for_message_and_history_record(
|
||||
message,
|
||||
UserMessageHistoryRecord::UserMessageText,
|
||||
next_label,
|
||||
)
|
||||
.0
|
||||
}
|
||||
|
||||
fn remap_user_messages_with_history_records(
|
||||
messages: Vec<(UserMessage, UserMessageHistoryRecord)>,
|
||||
) -> Vec<(UserMessage, UserMessageHistoryRecord)> {
|
||||
let total_remote_images = messages
|
||||
.iter()
|
||||
.map(|(message, _)| message.remote_image_urls.len())
|
||||
.sum::<usize>();
|
||||
let mut next_image_label = total_remote_images + 1;
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|(message, history_record)| {
|
||||
remap_placeholders_for_message_and_history_record(
|
||||
message,
|
||||
history_record,
|
||||
&mut next_image_label,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
|
||||
let messages = remap_user_messages_with_history_records(
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| (message, UserMessageHistoryRecord::UserMessageText))
|
||||
.collect(),
|
||||
);
|
||||
merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message))
|
||||
}
|
||||
|
||||
fn merge_remapped_user_messages(messages: impl IntoIterator<Item = UserMessage>) -> UserMessage {
|
||||
let mut combined = UserMessage {
|
||||
text: String::new(),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
|
||||
for (idx, message) in messages.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
combined.text.push('\n');
|
||||
}
|
||||
let UserMessage {
|
||||
text,
|
||||
text_elements,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
mention_bindings,
|
||||
} = message;
|
||||
append_text_with_rebased_elements(
|
||||
&mut combined.text,
|
||||
&mut combined.text_elements,
|
||||
&text,
|
||||
text_elements,
|
||||
);
|
||||
combined.local_images.extend(local_images);
|
||||
combined.remote_image_urls.extend(remote_image_urls);
|
||||
combined.mention_bindings.extend(mention_bindings);
|
||||
}
|
||||
|
||||
combined
|
||||
}
|
||||
|
||||
fn user_message_for_restore(
|
||||
message: UserMessage,
|
||||
history_record: &UserMessageHistoryRecord,
|
||||
) -> UserMessage {
|
||||
match history_record {
|
||||
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage {
|
||||
text: history.text.clone(),
|
||||
text_elements: history.text_elements.clone(),
|
||||
..message
|
||||
},
|
||||
UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_message_preview_text(
|
||||
message: &UserMessage,
|
||||
history_record: Option<&UserMessageHistoryRecord>,
|
||||
) -> String {
|
||||
match history_record {
|
||||
Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => {
|
||||
history.text.clone()
|
||||
}
|
||||
Some(UserMessageHistoryRecord::Override(_))
|
||||
| Some(UserMessageHistoryRecord::UserMessageText)
|
||||
| None => message.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_message_display_for_history(
|
||||
message: UserMessage,
|
||||
history_record: &UserMessageHistoryRecord,
|
||||
) -> UserMessageDisplay {
|
||||
let message = user_message_for_restore(message, history_record);
|
||||
ChatWidget::user_message_display_from_parts(
|
||||
message.text,
|
||||
message.text_elements,
|
||||
message
|
||||
.local_images
|
||||
.into_iter()
|
||||
.map(|image| image.path)
|
||||
.collect(),
|
||||
message.remote_image_urls,
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_user_messages_with_history_record(
|
||||
messages: Vec<(UserMessage, UserMessageHistoryRecord)>,
|
||||
) -> (UserMessage, UserMessageHistoryRecord) {
|
||||
let messages = remap_user_messages_with_history_records(messages);
|
||||
let history_record = if messages
|
||||
.iter()
|
||||
.all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText)
|
||||
{
|
||||
UserMessageHistoryRecord::UserMessageText
|
||||
} else {
|
||||
let mut history_text = String::new();
|
||||
let mut history_text_elements = Vec::new();
|
||||
let mut history_segment_count = 0usize;
|
||||
let mut append_history_segment = |text: &str, text_elements: Vec<TextElement>| {
|
||||
if history_segment_count > 0 {
|
||||
history_text.push('\n');
|
||||
}
|
||||
append_text_with_rebased_elements(
|
||||
&mut history_text,
|
||||
&mut history_text_elements,
|
||||
text,
|
||||
text_elements,
|
||||
);
|
||||
history_segment_count += 1;
|
||||
};
|
||||
for (message, record) in &messages {
|
||||
match record {
|
||||
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => {
|
||||
append_history_segment(&history.text, history.text_elements.clone());
|
||||
}
|
||||
UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {}
|
||||
UserMessageHistoryRecord::Override(_)
|
||||
| UserMessageHistoryRecord::UserMessageText => {
|
||||
append_history_segment(&message.text, message.text_elements.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: history_text,
|
||||
text_elements: history_text_elements,
|
||||
})
|
||||
};
|
||||
(
|
||||
merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)),
|
||||
history_record,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ReplayKind {
|
||||
ResumeInitialMessages,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Unified exec bookkeeping state and helpers for `ChatWidget`.
|
||||
|
||||
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
|
||||
use crate::exec_command::split_command_string;
|
||||
|
||||
pub(super) struct RunningCommand {
|
||||
pub(super) command: Vec<String>,
|
||||
pub(super) parsed_cmd: Vec<ParsedCommand>,
|
||||
pub(super) source: ExecCommandSource,
|
||||
}
|
||||
|
||||
pub(super) struct UnifiedExecProcessSummary {
|
||||
pub(super) key: String,
|
||||
pub(super) call_id: String,
|
||||
pub(super) command_display: String,
|
||||
pub(super) recent_chunks: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) struct UnifiedExecWaitState {
|
||||
command_display: String,
|
||||
}
|
||||
|
||||
impl UnifiedExecWaitState {
|
||||
pub(super) fn new(command_display: String) -> Self {
|
||||
Self { command_display }
|
||||
}
|
||||
|
||||
pub(super) fn is_duplicate(&self, command_display: &str) -> bool {
|
||||
self.command_display == command_display
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct UnifiedExecWaitStreak {
|
||||
pub(super) process_id: String,
|
||||
pub(super) command_display: Option<String>,
|
||||
}
|
||||
|
||||
impl UnifiedExecWaitStreak {
|
||||
pub(super) fn new(process_id: String, command_display: Option<String>) -> Self {
|
||||
Self {
|
||||
process_id,
|
||||
command_display: command_display.filter(|display| !display.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_command_display(&mut self, command_display: Option<String>) {
|
||||
if self.command_display.is_some() {
|
||||
return;
|
||||
}
|
||||
self.command_display = command_display.filter(|display| !display.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_unified_exec_source(source: ExecCommandSource) -> bool {
|
||||
matches!(
|
||||
source,
|
||||
ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
|
||||
!parsed_cmd.is_empty()
|
||||
&& parsed_cmd
|
||||
.iter()
|
||||
.all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. }))
|
||||
}
|
||||
|
||||
pub(super) fn command_execution_command_and_parsed(
|
||||
command: &str,
|
||||
command_actions: &[codex_app_server_protocol::CommandAction],
|
||||
) -> (Vec<String>, Vec<ParsedCommand>) {
|
||||
(
|
||||
split_command_string(command),
|
||||
command_actions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(codex_app_server_protocol::CommandAction::into_core)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -60,6 +60,18 @@ const PLUGIN_ROW_PREFIX_WIDTH: usize = 6;
|
||||
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
|
||||
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(super) struct PluginListFetchState {
|
||||
pub(super) cache_cwd: Option<PathBuf>,
|
||||
pub(super) in_flight_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct PluginInstallAuthFlowState {
|
||||
plugin_display_name: String,
|
||||
next_app_index: usize,
|
||||
}
|
||||
|
||||
struct DelayedLoadingHeader {
|
||||
started_at: Instant,
|
||||
frame_requester: FrameRequester,
|
||||
@@ -474,7 +486,7 @@ impl ChatWidget {
|
||||
self.plugin_install_apps_needing_auth.len()
|
||||
)),
|
||||
);
|
||||
self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState {
|
||||
self.plugin_install_auth_flow = Some(PluginInstallAuthFlowState {
|
||||
plugin_display_name,
|
||||
next_app_index: 0,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Rate-limit warning and prompt state for `ChatWidget`.
|
||||
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
|
||||
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
||||
pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RateLimitWarningState {
|
||||
pub(super) secondary_index: usize,
|
||||
pub(super) primary_index: usize,
|
||||
}
|
||||
|
||||
impl RateLimitWarningState {
|
||||
pub(super) fn take_warnings(
|
||||
&mut self,
|
||||
secondary_used_percent: Option<f64>,
|
||||
secondary_window_minutes: Option<i64>,
|
||||
primary_used_percent: Option<f64>,
|
||||
primary_window_minutes: Option<i64>,
|
||||
) -> Vec<String> {
|
||||
let reached_secondary_cap =
|
||||
matches!(secondary_used_percent, Some(percent) if percent == 100.0);
|
||||
let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);
|
||||
if reached_secondary_cap || reached_primary_cap {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if let Some(secondary_used_percent) = secondary_used_percent {
|
||||
let mut highest_secondary: Option<f64> = None;
|
||||
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
||||
{
|
||||
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
||||
self.secondary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_secondary {
|
||||
let limit_label = secondary_window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string());
|
||||
let remaining_percent = 100.0 - threshold;
|
||||
warnings.push(format!(
|
||||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(primary_used_percent) = primary_used_percent {
|
||||
let mut highest_primary: Option<f64> = None;
|
||||
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
||||
{
|
||||
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
||||
self.primary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_primary {
|
||||
let limit_label = primary_window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string());
|
||||
let remaining_percent = 100.0 - threshold;
|
||||
warnings.push(format!(
|
||||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
|
||||
const MINUTES_PER_HOUR: i64 = 60;
|
||||
const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;
|
||||
const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY;
|
||||
const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY;
|
||||
const ROUNDING_BIAS_MINUTES: i64 = 3;
|
||||
|
||||
let windows_minutes = windows_minutes.max(0);
|
||||
|
||||
if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES);
|
||||
let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR);
|
||||
format!("{hours}h")
|
||||
} else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
"weekly".to_string()
|
||||
} else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
"monthly".to_string()
|
||||
} else {
|
||||
"annual".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) enum RateLimitSwitchPromptState {
|
||||
#[default]
|
||||
Idle,
|
||||
Pending,
|
||||
Shown,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum RateLimitErrorKind {
|
||||
ServerOverloaded,
|
||||
UsageLimit,
|
||||
Generic,
|
||||
}
|
||||
|
||||
pub(super) fn app_server_rate_limit_error_kind(
|
||||
info: &AppServerCodexErrorInfo,
|
||||
) -> Option<RateLimitErrorKind> {
|
||||
match info {
|
||||
AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
|
||||
AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
|
||||
AppServerCodexErrorInfo::ResponseTooManyFailedAttempts {
|
||||
http_status_code: Some(429),
|
||||
} => Some(RateLimitErrorKind::Generic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
|
||||
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
|
||||
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
|
||||
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_preserves_text_elements_and_local_images() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
|
||||
@@ -1,17 +1,496 @@
|
||||
//! User-message display models and helpers for the chat widget.
|
||||
//! User-message models and helpers for the chat widget.
|
||||
//!
|
||||
//! The app-server preserves user input as structured chunks, while chat history
|
||||
//! renders a single prompt row. This module owns that display projection and
|
||||
//! the small compare key used to suppress duplicate rows for pending steers.
|
||||
//! renders a single prompt row. This module owns the draft/message data models,
|
||||
//! merge/remap behavior, display projection, and the small compare key used to
|
||||
//! suppress duplicate rows for pending steers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::ops::Deref;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::MentionBinding;
|
||||
use crate::bottom_pane::QueuedInputAction;
|
||||
use codex_app_server_protocol::TextElement as AppServerTextElement;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
|
||||
use super::ChatWidget;
|
||||
use super::append_text_with_rebased_elements;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct UserMessage {
|
||||
pub(super) text: String,
|
||||
pub(super) local_images: Vec<LocalImageAttachment>,
|
||||
/// Remote image attachments represented as URLs (for example data URLs)
|
||||
/// provided by app-server clients.
|
||||
///
|
||||
/// Unlike `local_images`, these are not created by TUI image attach/paste
|
||||
/// flows. The TUI can restore and remove them while editing/backtracking.
|
||||
pub(super) remote_image_urls: Vec<String>,
|
||||
pub(super) text_elements: Vec<TextElement>,
|
||||
pub(super) mention_bindings: Vec<MentionBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum UserMessageHistoryRecord {
|
||||
UserMessageText,
|
||||
Override(UserMessageHistoryOverride),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct UserMessageHistoryOverride {
|
||||
pub(super) text: String,
|
||||
pub(super) text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum ShellEscapePolicy {
|
||||
Allow,
|
||||
Disallow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(super) struct QueuedUserMessage {
|
||||
pub(super) user_message: UserMessage,
|
||||
pub(super) action: QueuedInputAction,
|
||||
}
|
||||
|
||||
impl QueuedUserMessage {
|
||||
pub(super) fn new(user_message: UserMessage, action: QueuedInputAction) -> Self {
|
||||
Self {
|
||||
user_message,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn into_user_message(self) -> UserMessage {
|
||||
self.user_message
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserMessage> for QueuedUserMessage {
|
||||
fn from(user_message: UserMessage) -> Self {
|
||||
Self::new(user_message, QueuedInputAction::Plain)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for QueuedUserMessage {
|
||||
type Target = UserMessage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.user_message
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum QueueDrain {
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub(super) struct ThreadComposerState {
|
||||
pub(super) text: String,
|
||||
pub(super) local_images: Vec<LocalImageAttachment>,
|
||||
pub(super) remote_image_urls: Vec<String>,
|
||||
pub(super) text_elements: Vec<TextElement>,
|
||||
pub(super) mention_bindings: Vec<MentionBinding>,
|
||||
pub(super) pending_pastes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl ThreadComposerState {
|
||||
pub(super) fn has_content(&self) -> bool {
|
||||
!self.text.is_empty()
|
||||
|| !self.local_images.is_empty()
|
||||
|| !self.remote_image_urls.is_empty()
|
||||
|| !self.text_elements.is_empty()
|
||||
|| !self.mention_bindings.is_empty()
|
||||
|| !self.pending_pastes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ThreadInputState {
|
||||
pub(super) composer: Option<ThreadComposerState>,
|
||||
pub(super) pending_steers: VecDeque<UserMessage>,
|
||||
pub(super) pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pub(super) pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
|
||||
pub(super) rejected_steers_queue: VecDeque<UserMessage>,
|
||||
pub(super) rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
|
||||
pub(super) queued_user_message_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pub(super) user_turn_pending_start: bool,
|
||||
pub(super) current_collaboration_mode: CollaborationMode,
|
||||
pub(super) active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
pub(super) task_running: bool,
|
||||
pub(super) agent_turn_running: bool,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for UserMessage {
|
||||
fn from(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct PendingSteer {
|
||||
pub(super) user_message: UserMessage,
|
||||
pub(super) history_record: UserMessageHistoryRecord,
|
||||
pub(super) compare_key: PendingSteerCompareKey,
|
||||
}
|
||||
|
||||
pub(crate) fn create_initial_user_message(
|
||||
text: Option<String>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
text_elements: Vec<TextElement>,
|
||||
) -> Option<UserMessage> {
|
||||
let text = text.unwrap_or_default();
|
||||
if text.is_empty() && local_image_paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let local_images = local_image_paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, path)| LocalImageAttachment {
|
||||
placeholder: local_image_label_text(idx + 1),
|
||||
path,
|
||||
})
|
||||
.collect();
|
||||
Some(UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements,
|
||||
mention_bindings: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn append_text_with_rebased_elements(
|
||||
target_text: &mut String,
|
||||
target_text_elements: &mut Vec<TextElement>,
|
||||
text: &str,
|
||||
text_elements: impl IntoIterator<Item = TextElement>,
|
||||
) {
|
||||
let offset = target_text.len();
|
||||
target_text.push_str(text);
|
||||
target_text_elements.extend(text_elements.into_iter().map(|mut element| {
|
||||
element.byte_range.start += offset;
|
||||
element.byte_range.end += offset;
|
||||
element
|
||||
}));
|
||||
}
|
||||
|
||||
pub(super) fn app_server_text_elements(elements: &[TextElement]) -> Vec<AppServerTextElement> {
|
||||
elements.iter().cloned().map(Into::into).collect()
|
||||
}
|
||||
|
||||
fn build_placeholder_mapping(
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
next_label: &mut usize,
|
||||
) -> (HashMap<String, String>, Vec<LocalImageAttachment>) {
|
||||
let mut mapping: HashMap<String, String> = HashMap::new();
|
||||
let mut remapped_images = Vec::new();
|
||||
for attachment in local_images {
|
||||
let new_placeholder = local_image_label_text(*next_label);
|
||||
*next_label += 1;
|
||||
mapping.insert(attachment.placeholder.clone(), new_placeholder.clone());
|
||||
remapped_images.push(LocalImageAttachment {
|
||||
placeholder: new_placeholder,
|
||||
path: attachment.path,
|
||||
});
|
||||
}
|
||||
(mapping, remapped_images)
|
||||
}
|
||||
|
||||
fn remap_placeholders_in_text(
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
mapping: &HashMap<String, String>,
|
||||
) -> (String, Vec<TextElement>) {
|
||||
if mapping.is_empty() {
|
||||
return (text, text_elements);
|
||||
}
|
||||
|
||||
let mut elements = text_elements;
|
||||
elements.sort_by_key(|elem| elem.byte_range.start);
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let mut rebuilt = String::new();
|
||||
let mut rebuilt_elements = Vec::new();
|
||||
for mut elem in elements {
|
||||
let start = elem.byte_range.start.min(text.len());
|
||||
let end = elem.byte_range.end.min(text.len());
|
||||
if let Some(segment) = text.get(cursor..start) {
|
||||
rebuilt.push_str(segment);
|
||||
}
|
||||
|
||||
let original = text.get(start..end).unwrap_or("");
|
||||
let placeholder = elem.placeholder(&text);
|
||||
let replacement = placeholder
|
||||
.and_then(|ph| mapping.get(ph))
|
||||
.map(String::as_str)
|
||||
.unwrap_or(original);
|
||||
|
||||
let elem_start = rebuilt.len();
|
||||
rebuilt.push_str(replacement);
|
||||
let elem_end = rebuilt.len();
|
||||
|
||||
if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) {
|
||||
elem.set_placeholder(Some(remapped.clone()));
|
||||
}
|
||||
elem.byte_range = (elem_start..elem_end).into();
|
||||
rebuilt_elements.push(elem);
|
||||
cursor = end;
|
||||
}
|
||||
if let Some(segment) = text.get(cursor..) {
|
||||
rebuilt.push_str(segment);
|
||||
}
|
||||
|
||||
(rebuilt, rebuilt_elements)
|
||||
}
|
||||
|
||||
// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering
|
||||
// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so
|
||||
// the combined local_image_paths order matches the labels, even if placeholders were moved
|
||||
// in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to
|
||||
// history overrides so restored drafts and rendered transcript entries agree.
|
||||
fn remap_placeholders_for_message_and_history_record(
|
||||
message: UserMessage,
|
||||
history_record: UserMessageHistoryRecord,
|
||||
next_label: &mut usize,
|
||||
) -> (UserMessage, UserMessageHistoryRecord) {
|
||||
let UserMessage {
|
||||
text,
|
||||
text_elements,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
mention_bindings,
|
||||
} = message;
|
||||
let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label);
|
||||
let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping);
|
||||
let history_record = match history_record {
|
||||
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => {
|
||||
let (text, text_elements) =
|
||||
remap_placeholders_in_text(history.text, history.text_elements, &mapping);
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text,
|
||||
text_elements,
|
||||
})
|
||||
}
|
||||
record => record,
|
||||
};
|
||||
|
||||
(
|
||||
UserMessage {
|
||||
text,
|
||||
local_images: remapped_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings,
|
||||
},
|
||||
history_record,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn remap_placeholders_for_message(
|
||||
message: UserMessage,
|
||||
next_label: &mut usize,
|
||||
) -> UserMessage {
|
||||
remap_placeholders_for_message_and_history_record(
|
||||
message,
|
||||
UserMessageHistoryRecord::UserMessageText,
|
||||
next_label,
|
||||
)
|
||||
.0
|
||||
}
|
||||
|
||||
fn remap_user_messages_with_history_records(
|
||||
messages: Vec<(UserMessage, UserMessageHistoryRecord)>,
|
||||
) -> Vec<(UserMessage, UserMessageHistoryRecord)> {
|
||||
let total_remote_images = messages
|
||||
.iter()
|
||||
.map(|(message, _)| message.remote_image_urls.len())
|
||||
.sum::<usize>();
|
||||
let mut next_image_label = total_remote_images + 1;
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|(message, history_record)| {
|
||||
remap_placeholders_for_message_and_history_record(
|
||||
message,
|
||||
history_record,
|
||||
&mut next_image_label,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
|
||||
let messages = remap_user_messages_with_history_records(
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| (message, UserMessageHistoryRecord::UserMessageText))
|
||||
.collect(),
|
||||
);
|
||||
merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message))
|
||||
}
|
||||
|
||||
fn merge_remapped_user_messages(messages: impl IntoIterator<Item = UserMessage>) -> UserMessage {
|
||||
let mut combined = UserMessage {
|
||||
text: String::new(),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
|
||||
for (idx, message) in messages.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
combined.text.push('\n');
|
||||
}
|
||||
let UserMessage {
|
||||
text,
|
||||
text_elements,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
mention_bindings,
|
||||
} = message;
|
||||
append_text_with_rebased_elements(
|
||||
&mut combined.text,
|
||||
&mut combined.text_elements,
|
||||
&text,
|
||||
text_elements,
|
||||
);
|
||||
combined.local_images.extend(local_images);
|
||||
combined.remote_image_urls.extend(remote_image_urls);
|
||||
combined.mention_bindings.extend(mention_bindings);
|
||||
}
|
||||
|
||||
combined
|
||||
}
|
||||
|
||||
pub(super) fn user_message_for_restore(
|
||||
message: UserMessage,
|
||||
history_record: &UserMessageHistoryRecord,
|
||||
) -> UserMessage {
|
||||
match history_record {
|
||||
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage {
|
||||
text: history.text.clone(),
|
||||
text_elements: history.text_elements.clone(),
|
||||
..message
|
||||
},
|
||||
UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn user_message_preview_text(
|
||||
message: &UserMessage,
|
||||
history_record: Option<&UserMessageHistoryRecord>,
|
||||
) -> String {
|
||||
match history_record {
|
||||
Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => {
|
||||
history.text.clone()
|
||||
}
|
||||
Some(UserMessageHistoryRecord::Override(_))
|
||||
| Some(UserMessageHistoryRecord::UserMessageText)
|
||||
| None => message.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn user_message_display_for_history(
|
||||
message: UserMessage,
|
||||
history_record: &UserMessageHistoryRecord,
|
||||
) -> UserMessageDisplay {
|
||||
let message = user_message_for_restore(message, history_record);
|
||||
ChatWidget::user_message_display_from_parts(
|
||||
message.text,
|
||||
message.text_elements,
|
||||
message
|
||||
.local_images
|
||||
.into_iter()
|
||||
.map(|image| image.path)
|
||||
.collect(),
|
||||
message.remote_image_urls,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn merge_user_messages_with_history_record(
|
||||
messages: Vec<(UserMessage, UserMessageHistoryRecord)>,
|
||||
) -> (UserMessage, UserMessageHistoryRecord) {
|
||||
let messages = remap_user_messages_with_history_records(messages);
|
||||
let history_record = if messages
|
||||
.iter()
|
||||
.all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText)
|
||||
{
|
||||
UserMessageHistoryRecord::UserMessageText
|
||||
} else {
|
||||
let mut history_text = String::new();
|
||||
let mut history_text_elements = Vec::new();
|
||||
let mut history_segment_count = 0usize;
|
||||
let mut append_history_segment = |text: &str, text_elements: Vec<TextElement>| {
|
||||
if history_segment_count > 0 {
|
||||
history_text.push('\n');
|
||||
}
|
||||
append_text_with_rebased_elements(
|
||||
&mut history_text,
|
||||
&mut history_text_elements,
|
||||
text,
|
||||
text_elements,
|
||||
);
|
||||
history_segment_count += 1;
|
||||
};
|
||||
for (message, record) in &messages {
|
||||
match record {
|
||||
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => {
|
||||
append_history_segment(&history.text, history.text_elements.clone());
|
||||
}
|
||||
UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {}
|
||||
UserMessageHistoryRecord::Override(_)
|
||||
| UserMessageHistoryRecord::UserMessageText => {
|
||||
append_history_segment(&message.text, message.text_elements.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: history_text,
|
||||
text_elements: history_text_elements,
|
||||
})
|
||||
};
|
||||
|
||||
(
|
||||
merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)),
|
||||
history_record,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct UserMessageDisplay {
|
||||
|
||||
Reference in New Issue
Block a user