mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
## Why TUI preferences and persistence paths belong to the local client, while thread configuration and account requirements may come from the active app server. Keeping both in the same resolved `Config` can replace live local preferences when a thread is resumed, forked, reconnected, or switched. ## What changed - Add `LocalSettings` as the TUI-owned source for UI, history, notice, and local persistence settings, and preserve it across session lifecycle transitions. - Write preference changes to the selected user config file and reload local settings from disk when changing roots. - Use the app server's account response for authentication-dependent onboarding and status UI instead of the local model-provider configuration. - Retain platform family and OS metadata reported by remote app servers. ## Testing - Cover local setting defaults, overrides, persistence destinations, reloads, and preservation across widget replacement and root switching. - Cover remote platform metadata and server-controlled authentication UI. GitOrigin-RevId: dbc2965fb1d6cbecb3f973f6133f3dcd4467b753
313 lines
11 KiB
Rust
313 lines
11 KiB
Rust
//! Render persisted thread turns into history-cell building blocks.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::app_server_session::AppServerSession;
|
|
use crate::app_server_session::HistoryHydrationScope;
|
|
use crate::git_action_directives::parse_assistant_markdown;
|
|
use crate::history_cell::AgentMarkdownCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::PlainHistoryCell;
|
|
use crate::history_cell::PrefixedWrappedHistoryCell;
|
|
use crate::history_cell::ReasoningSummaryCell;
|
|
use crate::history_cell::UserHistoryCell;
|
|
use crate::history_cell::split_reasoning_summary_parts;
|
|
use crate::inline_visualization::InlineVisualizationContext;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::multi_agents::sub_agent_activity_summary;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use ratatui::style::Stylize as _;
|
|
use ratatui::text::Line;
|
|
|
|
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum RawReasoningVisibility {
|
|
Hidden,
|
|
Visible,
|
|
}
|
|
|
|
pub(crate) async fn load_session_transcript(
|
|
app_server: &mut AppServerSession,
|
|
thread_id: ThreadId,
|
|
raw_reasoning_visibility: RawReasoningVisibility,
|
|
config: Option<&Config>,
|
|
) -> std::io::Result<TranscriptCells> {
|
|
let mut thread = app_server
|
|
.thread_read(thread_id, /*include_turns*/ false)
|
|
.await
|
|
.map_err(std::io::Error::other)?;
|
|
app_server
|
|
.hydrate_initial_thread_history(
|
|
&mut thread,
|
|
/*turn_cursor*/ None,
|
|
/*item_cursor*/ None,
|
|
/*config*/ None,
|
|
/*local_settings*/ None,
|
|
HistoryHydrationScope::Complete,
|
|
)
|
|
.await
|
|
.map_err(std::io::Error::other)?;
|
|
Ok(thread_to_transcript_cells(
|
|
thread,
|
|
raw_reasoning_visibility,
|
|
config,
|
|
))
|
|
}
|
|
|
|
pub(crate) fn thread_to_transcript_cells(
|
|
thread: Thread,
|
|
raw_reasoning_visibility: RawReasoningVisibility,
|
|
config: Option<&Config>,
|
|
) -> TranscriptCells {
|
|
let cwd = thread.cwd;
|
|
let thread_id = ThreadId::from_string(&thread.id).ok();
|
|
let mut cells = thread_items_to_transcript_cells(
|
|
thread_id,
|
|
&cwd,
|
|
thread.turns.into_iter().flat_map(|turn| turn.items),
|
|
raw_reasoning_visibility,
|
|
config,
|
|
);
|
|
if cells.is_empty() {
|
|
cells.push(Arc::new(PlainHistoryCell::new(vec![
|
|
"No transcript content available".italic().dim().into(),
|
|
])));
|
|
}
|
|
cells
|
|
}
|
|
|
|
pub(crate) fn thread_items_to_transcript_cells(
|
|
thread_id: Option<ThreadId>,
|
|
cwd: &AbsolutePathBuf,
|
|
items: impl IntoIterator<Item = ThreadItem>,
|
|
raw_reasoning_visibility: RawReasoningVisibility,
|
|
config: Option<&Config>,
|
|
) -> TranscriptCells {
|
|
let inline_visualization_context = config.and_then(|config| {
|
|
thread_id.and_then(|thread_id| InlineVisualizationContext::from_config(config, thread_id))
|
|
});
|
|
let mut cells: TranscriptCells = Vec::new();
|
|
for item in items {
|
|
match item {
|
|
ThreadItem::UserMessage {
|
|
id,
|
|
client_id,
|
|
content,
|
|
} => {
|
|
if content.iter().any(|input| {
|
|
matches!(
|
|
input,
|
|
UserInput::Audio { .. } | UserInput::LocalAudio { .. }
|
|
)
|
|
}) {
|
|
tracing::warn!(
|
|
user_message_id = id,
|
|
"audio user inputs are not supported by the TUI and will be omitted"
|
|
);
|
|
}
|
|
let item = UserMessageItem {
|
|
id,
|
|
client_id,
|
|
content: content
|
|
.into_iter()
|
|
.map(codex_app_server_protocol::UserInput::into_core)
|
|
.collect(),
|
|
};
|
|
cells.push(Arc::new(UserHistoryCell {
|
|
message: item.message(),
|
|
text_elements: item.text_elements(),
|
|
local_image_paths: item.local_image_paths(),
|
|
remote_image_urls: item.image_urls(),
|
|
}));
|
|
}
|
|
ThreadItem::AgentMessage { text, .. } => {
|
|
let parsed = parse_assistant_markdown(&text, cwd.as_path());
|
|
if !parsed.visible_markdown.trim().is_empty() {
|
|
cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
|
|
parsed.visible_markdown,
|
|
cwd.as_path(),
|
|
inline_visualization_context.clone(),
|
|
)));
|
|
}
|
|
}
|
|
ThreadItem::FunctionCallOutput {
|
|
name,
|
|
namespace,
|
|
output,
|
|
..
|
|
} => {
|
|
if let Some((source_thread_id, prompt)) =
|
|
crate::dynamic_tools::parse_delegated_tool_output(
|
|
&name,
|
|
namespace.as_deref(),
|
|
&output,
|
|
)
|
|
{
|
|
cells.push(Arc::new(PrefixedWrappedHistoryCell::new(
|
|
format!("Sent by Codex from task {source_thread_id}\n{prompt}"),
|
|
"• ".dim(),
|
|
" ",
|
|
)));
|
|
}
|
|
}
|
|
ThreadItem::Plan { text, .. } => {
|
|
if !text.trim().is_empty() {
|
|
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
|
|
text,
|
|
cwd.as_path(),
|
|
)));
|
|
}
|
|
}
|
|
ThreadItem::Reasoning {
|
|
summary, content, ..
|
|
} => {
|
|
let (header, text) =
|
|
if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
|
|
&& !content.is_empty()
|
|
{
|
|
("Reasoning".to_string(), content.join("\n\n"))
|
|
} else {
|
|
split_reasoning_summary_parts(&summary)
|
|
};
|
|
if !text.trim().is_empty() {
|
|
cells.push(Arc::new(ReasoningSummaryCell::new(
|
|
header,
|
|
text,
|
|
cwd.as_path(),
|
|
/*transcript_only*/ false,
|
|
)));
|
|
}
|
|
}
|
|
other => {
|
|
if let Some(cell) = fallback_transcript_cell(&other) {
|
|
cells.push(Arc::new(cell));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cells
|
|
}
|
|
|
|
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
|
|
let lines = match item {
|
|
ThreadItem::HookPrompt { fragments, .. } => fragments
|
|
.iter()
|
|
.map(|fragment| {
|
|
vec![
|
|
"hook prompt: ".dim(),
|
|
fragment.text.trim().to_string().into(),
|
|
]
|
|
.into()
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
ThreadItem::CommandExecution {
|
|
command,
|
|
status,
|
|
aggregated_output,
|
|
exit_code,
|
|
..
|
|
} => {
|
|
let mut lines: Vec<Line<'static>> =
|
|
vec![vec!["$ ".dim(), command.clone().into()].into()];
|
|
lines.push(
|
|
format!(
|
|
"status: {status:?}{}",
|
|
exit_code
|
|
.map(|code| format!(" · exit {code}"))
|
|
.unwrap_or_default()
|
|
)
|
|
.dim()
|
|
.into(),
|
|
);
|
|
if let Some(output) = aggregated_output.as_deref()
|
|
&& !output.trim().is_empty()
|
|
{
|
|
lines.extend(
|
|
output
|
|
.lines()
|
|
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
|
|
);
|
|
}
|
|
lines
|
|
}
|
|
ThreadItem::FileChange {
|
|
changes, status, ..
|
|
} => vec![
|
|
format!("file changes: {status:?} · {} changes", changes.len())
|
|
.dim()
|
|
.into(),
|
|
],
|
|
ThreadItem::McpToolCall {
|
|
server,
|
|
tool,
|
|
status,
|
|
..
|
|
} => vec![
|
|
format!("mcp tool: {server}/{tool} · {status:?}")
|
|
.dim()
|
|
.into(),
|
|
],
|
|
ThreadItem::DynamicToolCall {
|
|
namespace,
|
|
tool,
|
|
status,
|
|
..
|
|
} => {
|
|
let name = namespace
|
|
.as_ref()
|
|
.map(|namespace| format!("{namespace}/{tool}"))
|
|
.unwrap_or_else(|| tool.clone());
|
|
vec![format!("tool: {name} · {status:?}").dim().into()]
|
|
}
|
|
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
|
|
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
|
|
}
|
|
ThreadItem::SubAgentActivity {
|
|
kind, agent_path, ..
|
|
} => {
|
|
vec![sub_agent_activity_summary(*kind, agent_path).dim().into()]
|
|
}
|
|
ThreadItem::WebSearch(item) => {
|
|
vec![vec!["web search: ".dim(), item.query.clone().into()].into()]
|
|
}
|
|
ThreadItem::ImageView { path, .. } => {
|
|
let path = path.render_for_ui();
|
|
vec![format!("image: {path}").dim().into()]
|
|
}
|
|
ThreadItem::ImageGeneration(item) => {
|
|
let saved = item
|
|
.saved_path
|
|
.as_ref()
|
|
.map(|path| format!(" · {}", path.as_path().display()))
|
|
.unwrap_or_default();
|
|
vec![
|
|
format!("image generation: {}{saved}", item.status)
|
|
.dim()
|
|
.into(),
|
|
]
|
|
}
|
|
ThreadItem::EnteredReviewMode { review, .. } => {
|
|
vec![vec!["review started: ".dim(), review.clone().into()].into()]
|
|
}
|
|
ThreadItem::ExitedReviewMode { review, .. } => {
|
|
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
|
|
}
|
|
ThreadItem::ContextCompaction { .. } => {
|
|
vec!["context compacted".dim().into()]
|
|
}
|
|
ThreadItem::UserMessage { .. }
|
|
| ThreadItem::AgentMessage { .. }
|
|
| ThreadItem::FunctionCallOutput { .. }
|
|
| ThreadItem::Plan { .. }
|
|
| ThreadItem::Reasoning { .. }
|
|
| ThreadItem::Sleep(_) => return None,
|
|
};
|
|
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
|
|
}
|