mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Merge branch 'main' into jif-oai/macos-snapshots
This commit is contained in:
Vendored
+6
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml",
|
||||
"vadimcn.vscode-lldb",
|
||||
|
||||
// Useful if touching files in .github/workflows, though most
|
||||
// contributors will not be doing that?
|
||||
// "github.vscode-github-actions",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config_profile::ConfigProfile;
|
||||
use crate::config_types::History;
|
||||
use crate::config_types::McpServerConfig;
|
||||
use crate::config_types::ReasoningSummaryFormat;
|
||||
use crate::config_types::SandboxWorkspaceWrite;
|
||||
use crate::config_types::ShellEnvironmentPolicy;
|
||||
use crate::config_types::ShellEnvironmentPolicyToml;
|
||||
@@ -185,8 +186,6 @@ pub struct Config {
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
pub disable_paste_burst: bool,
|
||||
|
||||
pub use_experimental_reasoning_summary: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -473,6 +472,9 @@ pub struct ConfigToml {
|
||||
/// Override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
|
||||
/// Override to force reasoning summary format for the configured model.
|
||||
pub model_reasoning_summary_format: Option<ReasoningSummaryFormat>,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
|
||||
@@ -484,8 +486,6 @@ pub struct ConfigToml {
|
||||
|
||||
pub experimental_use_exec_command_tool: Option<bool>,
|
||||
|
||||
pub use_experimental_reasoning_summary: Option<bool>,
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub responses_originator_header_internal_override: Option<String>,
|
||||
|
||||
@@ -713,11 +713,15 @@ impl Config {
|
||||
let model_family = find_family_for_model(&model).unwrap_or_else(|| {
|
||||
let supports_reasoning_summaries =
|
||||
cfg.model_supports_reasoning_summaries.unwrap_or(false);
|
||||
let reasoning_summary_format = cfg
|
||||
.model_reasoning_summary_format
|
||||
.unwrap_or(ReasoningSummaryFormat::None);
|
||||
ModelFamily {
|
||||
slug: model.clone(),
|
||||
family: model.clone(),
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries,
|
||||
reasoning_summary_format,
|
||||
uses_local_shell_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
}
|
||||
@@ -811,9 +815,6 @@ impl Config {
|
||||
.unwrap_or(false),
|
||||
include_view_image_tool,
|
||||
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
|
||||
use_experimental_reasoning_summary: cfg
|
||||
.use_experimental_reasoning_summary
|
||||
.unwrap_or(false),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -1192,7 +1193,6 @@ model_verbosity = "high"
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
use_experimental_reasoning_summary: false,
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -1251,7 +1251,6 @@ model_verbosity = "high"
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
use_experimental_reasoning_summary: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -1325,7 +1324,6 @@ model_verbosity = "high"
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
use_experimental_reasoning_summary: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -1385,7 +1383,6 @@ model_verbosity = "high"
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
use_experimental_reasoning_summary: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
|
||||
|
||||
@@ -183,3 +183,11 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default, Hash)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReasoningSummaryFormat {
|
||||
#[default]
|
||||
None,
|
||||
Experimental,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config_types::ReasoningSummaryFormat;
|
||||
use crate::tool_apply_patch::ApplyPatchToolType;
|
||||
|
||||
/// A model family is a group of models that share certain characteristics.
|
||||
@@ -20,6 +21,9 @@ pub struct ModelFamily {
|
||||
// `summary` is optional).
|
||||
pub supports_reasoning_summaries: bool,
|
||||
|
||||
// Define if we need a special handling of reasoning summary
|
||||
pub reasoning_summary_format: ReasoningSummaryFormat,
|
||||
|
||||
// This should be set to true when the model expects a tool named
|
||||
// "local_shell" to be provided. Its contract must be understood natively by
|
||||
// the model such that its description can be omitted.
|
||||
@@ -41,6 +45,7 @@ macro_rules! model_family {
|
||||
family: $family.to_string(),
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries: false,
|
||||
reasoning_summary_format: ReasoningSummaryFormat::None,
|
||||
uses_local_shell_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
};
|
||||
@@ -61,6 +66,7 @@ macro_rules! simple_model_family {
|
||||
family: $family.to_string(),
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries: false,
|
||||
reasoning_summary_format: ReasoningSummaryFormat::None,
|
||||
uses_local_shell_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
})
|
||||
@@ -90,6 +96,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
model_family!(
|
||||
slug, slug,
|
||||
supports_reasoning_summaries: true,
|
||||
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
|
||||
)
|
||||
} else if slug.starts_with("gpt-4.1") {
|
||||
model_family!(
|
||||
|
||||
@@ -17,7 +17,7 @@ mime_guess = "2.0.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
serde_with = "3.14.0"
|
||||
serde_with = { version = "3.14.0", features = ["macros", "base64"] }
|
||||
strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
tracing = "0.1.41"
|
||||
|
||||
@@ -774,22 +774,22 @@ pub struct ExecCommandEndEvent {
|
||||
pub formatted_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecOutputStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct ExecCommandOutputDeltaEvent {
|
||||
/// Identifier for the ExecCommandBegin that produced this chunk.
|
||||
pub call_id: String,
|
||||
/// Which stream produced this chunk.
|
||||
pub stream: ExecOutputStream,
|
||||
/// Raw bytes from the stream (may not be valid UTF-8).
|
||||
#[serde_as(as = "Base64")]
|
||||
#[serde_as(as = "serde_with::base64::Base64")]
|
||||
pub chunk: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -980,4 +980,21 @@ mod tests {
|
||||
r#"{"id":"1234","msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_u8_as_base64_serialization_and_deserialization() {
|
||||
let event = ExecCommandOutputDeltaEvent {
|
||||
call_id: "call21".to_string(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: vec![1, 2, 3, 4, 5],
|
||||
};
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(
|
||||
r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#,
|
||||
serialized,
|
||||
);
|
||||
|
||||
let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(deserialized, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -37,6 +36,7 @@ use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
use crate::clipboard_paste::pasted_image_format;
|
||||
use crate::key_hint;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
@@ -1259,35 +1259,35 @@ impl WidgetRef for ChatComposer {
|
||||
}
|
||||
ActivePopup::None => {
|
||||
let bottom_line_rect = popup_rect;
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
let mut hint = if self.ctrl_c_quit_hint {
|
||||
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
|
||||
vec![
|
||||
" ".into(),
|
||||
"Ctrl+C again".set_style(key_hint_style),
|
||||
key_hint::ctrl('C'),
|
||||
" again".into(),
|
||||
" to quit".into(),
|
||||
]
|
||||
} else {
|
||||
let newline_hint_key = if self.use_shift_enter_hint {
|
||||
"Shift+⏎"
|
||||
key_hint::shift('⏎')
|
||||
} else {
|
||||
"Ctrl+J"
|
||||
key_hint::ctrl('J')
|
||||
};
|
||||
vec![
|
||||
" ".into(),
|
||||
"⏎".set_style(key_hint_style),
|
||||
key_hint::plain('⏎'),
|
||||
" send ".into(),
|
||||
newline_hint_key.set_style(key_hint_style),
|
||||
newline_hint_key,
|
||||
" newline ".into(),
|
||||
"Ctrl+T".set_style(key_hint_style),
|
||||
key_hint::ctrl('T'),
|
||||
" transcript ".into(),
|
||||
"Ctrl+C".set_style(key_hint_style),
|
||||
key_hint::ctrl('C'),
|
||||
" quit".into(),
|
||||
]
|
||||
};
|
||||
|
||||
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
|
||||
hint.push(" ".into());
|
||||
hint.push("Esc".set_style(key_hint_style));
|
||||
hint.push(key_hint::plain("Esc"));
|
||||
hint.push(" edit prev".into());
|
||||
}
|
||||
|
||||
@@ -1635,7 +1635,6 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
@@ -1687,13 +1686,12 @@ mod tests {
|
||||
.draw(|f| f.render_widget_ref(composer, f.area()))
|
||||
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
||||
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
insta::assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_model_first_for_mo_ui() {
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
@@ -1720,7 +1718,7 @@ mod tests {
|
||||
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
|
||||
|
||||
// Visual snapshot should show the slash popup with /model as the first entry.
|
||||
assert_snapshot!("slash_popup_mo", terminal.backend());
|
||||
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
"▌ "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
"▌ "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
"▌ "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
"▌ "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
"▌ "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ Ask Codex to do anything "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transc"
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃"
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ expression: visual
|
||||
Investigating rendering code (0s • Esc to interrupt)
|
||||
|
||||
▌Summarize recent commits
|
||||
⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit
|
||||
⏎ send ⌃J newline ⌃T transcript ⌃C quit
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@ expression: terminal.backend()
|
||||
" Analyzing (0s • Esc to interrupt) "
|
||||
" "
|
||||
"▌ Ask Codex to do anything "
|
||||
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
" "
|
||||
|
||||
@@ -1075,8 +1075,6 @@ fn apply_patch_manual_approval_adjusts_header() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_manual_flow_snapshot() {
|
||||
use insta::assert_snapshot;
|
||||
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
let mut proposed_changes = HashMap::new();
|
||||
@@ -1647,5 +1645,5 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
}
|
||||
|
||||
let visual = vt_lines.join("\n");
|
||||
insta::assert_snapshot!(visual);
|
||||
assert_snapshot!(visual);
|
||||
}
|
||||
|
||||
+144
-126
@@ -11,6 +11,7 @@ use codex_common::elapsed::format_duration;
|
||||
use codex_core::auth::get_auth_file;
|
||||
use codex_core::auth::try_read_auth_json;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_types::ReasoningSummaryFormat;
|
||||
use codex_core::plan_tool::PlanItemArg;
|
||||
use codex_core::plan_tool::StepStatus;
|
||||
use codex_core::plan_tool::UpdatePlanArgs;
|
||||
@@ -1244,34 +1245,39 @@ pub(crate) fn new_reasoning_summary_block(
|
||||
full_reasoning_buffer: String,
|
||||
config: &Config,
|
||||
) -> Vec<Box<dyn HistoryCell>> {
|
||||
if config.use_experimental_reasoning_summary {
|
||||
if config.model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental {
|
||||
// Experimental format is following:
|
||||
// ** header **
|
||||
//
|
||||
// reasoning summary
|
||||
//
|
||||
// So we need to strip header from reasoning summary
|
||||
let full_reasoning_buffer = full_reasoning_buffer.trim();
|
||||
if let Some(open) = full_reasoning_buffer.find("**") {
|
||||
let after_open = &full_reasoning_buffer[(open + 2)..];
|
||||
if let Some(close) = after_open.find("**") {
|
||||
let after_close_idx = open + 2 + close + 2;
|
||||
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
|
||||
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
|
||||
// if we don't have anything beyond `after_close_idx`
|
||||
// then we don't have a summary to inject into history
|
||||
if after_close_idx < full_reasoning_buffer.len() {
|
||||
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
|
||||
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
|
||||
|
||||
let mut header_lines: Vec<Line<'static>> = Vec::new();
|
||||
header_lines.push(Line::from("Thinking".magenta().italic()));
|
||||
append_markdown(&header_buffer, &mut header_lines, config);
|
||||
let mut header_lines: Vec<Line<'static>> = Vec::new();
|
||||
header_lines.push(Line::from("Thinking".magenta().italic()));
|
||||
append_markdown(&header_buffer, &mut header_lines, config);
|
||||
|
||||
let mut summary_lines: Vec<Line<'static>> = Vec::new();
|
||||
summary_lines.push(Line::from("Thinking".magenta().bold()));
|
||||
append_markdown(&summary_buffer, &mut summary_lines, config);
|
||||
let mut summary_lines: Vec<Line<'static>> = Vec::new();
|
||||
summary_lines.push(Line::from("Thinking".magenta().bold()));
|
||||
append_markdown(&summary_buffer, &mut summary_lines, config);
|
||||
|
||||
return vec![
|
||||
Box::new(TranscriptOnlyHistoryCell {
|
||||
lines: header_lines,
|
||||
}),
|
||||
Box::new(AgentMessageCell::new(summary_lines, true)),
|
||||
];
|
||||
return vec![
|
||||
Box::new(TranscriptOnlyHistoryCell {
|
||||
lines: header_lines,
|
||||
}),
|
||||
Box::new(AgentMessageCell::new(summary_lines, true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1369,6 +1375,34 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
|
||||
fn test_config() -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
|
||||
render_lines(&cell.transcript_lines())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_sequential_reads_within_one_call() {
|
||||
@@ -1409,16 +1443,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let lines = cell.display_lines(80);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1491,16 +1516,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let lines = cell.display_lines(80);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1538,16 +1554,7 @@ mod tests {
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let lines = cell.display_lines(80);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1579,16 +1586,7 @@ mod tests {
|
||||
// Small width to force wrapping on both lines
|
||||
let width: u16 = 28;
|
||||
let lines = cell.display_lines(width);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1615,16 +1613,7 @@ mod tests {
|
||||
);
|
||||
// Wide enough that it fits inline
|
||||
let lines = cell.display_lines(80);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1651,16 +1640,7 @@ mod tests {
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let lines = cell.display_lines(24);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1687,16 +1667,7 @@ mod tests {
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let lines = cell.display_lines(80);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1724,16 +1695,7 @@ mod tests {
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let lines = cell.display_lines(28);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1834,17 +1796,7 @@ mod tests {
|
||||
// Small width to force wrapping more clearly. Effective wrap width is width-1 due to the ▌ prefix.
|
||||
let width: u16 = 12;
|
||||
let lines = cell.display_lines(width);
|
||||
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
@@ -1876,16 +1828,7 @@ mod tests {
|
||||
let cell = new_plan_update(update);
|
||||
// Narrow width to force wrapping for both the note and steps
|
||||
let lines = cell.display_lines(32);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
@@ -1907,16 +1850,91 @@ mod tests {
|
||||
|
||||
let cell = new_plan_update(update);
|
||||
let lines = cell.display_lines(40);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_lines(&lines).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cells =
|
||||
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
|
||||
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = render_transcript(cells[0].as_ref());
|
||||
assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_block_falls_back_when_header_is_missing() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cells = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = render_transcript(cells[0].as_ref());
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["thinking", "**High level reasoning without closing"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_block_falls_back_when_summary_is_missing() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cells = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing**".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = render_transcript(cells[0].as_ref());
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["thinking", "High level reasoning without closing"]
|
||||
);
|
||||
|
||||
let cells = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing**\n\n ".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = render_transcript(cells[0].as_ref());
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["thinking", "High level reasoning without closing"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_block_splits_header_and_summary_when_present() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cells = new_reasoning_summary_block(
|
||||
"**High level plan**\n\nWe should fix the bug next.".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
assert_eq!(cells.len(), 2);
|
||||
|
||||
let header_lines = render_transcript(cells[0].as_ref());
|
||||
assert_eq!(header_lines, vec!["Thinking", "High level plan"]);
|
||||
|
||||
let summary_lines = render_transcript(cells[1].as_ref());
|
||||
|
||||
assert_eq!(
|
||||
summary_lines,
|
||||
vec!["codex", "Thinking", "We should fix the bug next."]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[cfg(test)]
|
||||
const ALT_PREFIX: &str = "⌥";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const ALT_PREFIX: &str = "⌥";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const ALT_PREFIX: &str = "Alt+";
|
||||
|
||||
#[cfg(test)]
|
||||
const CTRL_PREFIX: &str = "⌃";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const CTRL_PREFIX: &str = "⌃";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const CTRL_PREFIX: &str = "Ctrl+";
|
||||
|
||||
#[cfg(test)]
|
||||
const SHIFT_PREFIX: &str = "⇧";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const SHIFT_PREFIX: &str = "⇧";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const SHIFT_PREFIX: &str = "Shift+";
|
||||
|
||||
fn key_hint_style() -> Style {
|
||||
Style::default().fg(Color::Cyan)
|
||||
}
|
||||
|
||||
fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
|
||||
Span::styled(format!("{prefix}{key}"), key_hint_style())
|
||||
}
|
||||
|
||||
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
|
||||
modifier_span(CTRL_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn alt(key: impl Display) -> Span<'static> {
|
||||
modifier_span(ALT_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn shift(key: impl Display) -> Span<'static> {
|
||||
modifier_span(SHIFT_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn plain(key: impl Display) -> Span<'static> {
|
||||
Span::styled(format!("{key}"), key_hint_style())
|
||||
}
|
||||
@@ -42,6 +42,7 @@ mod file_search;
|
||||
mod get_git_diff;
|
||||
mod history_cell;
|
||||
pub mod insert_history;
|
||||
mod key_hint;
|
||||
pub mod live_wrap;
|
||||
mod markdown;
|
||||
mod markdown_stream;
|
||||
|
||||
@@ -77,6 +77,7 @@ struct PagerView {
|
||||
scroll_offset: usize,
|
||||
title: String,
|
||||
wrap_cache: Option<WrapCache>,
|
||||
last_content_height: Option<usize>,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
@@ -86,12 +87,14 @@ impl PagerView {
|
||||
scroll_offset,
|
||||
title,
|
||||
wrap_cache: None,
|
||||
last_content_height: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
self.render_header(area, buf);
|
||||
let content_area = self.scroll_area(area);
|
||||
self.update_last_content_height(content_area.height);
|
||||
self.ensure_wrapped(content_area.width);
|
||||
// Compute page bounds without holding an immutable borrow on cache while mutating self
|
||||
let wrapped_len = self
|
||||
@@ -119,6 +122,7 @@ impl PagerView {
|
||||
) {
|
||||
self.render_header(area, buf);
|
||||
let content_area = self.scroll_area(area);
|
||||
self.update_last_content_height(content_area.height);
|
||||
self.ensure_wrapped(content_area.width);
|
||||
// Compute page bounds first to avoid borrow conflicts
|
||||
let wrapped_len = self
|
||||
@@ -250,6 +254,10 @@ impl PagerView {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_last_content_height(&mut self, height: u16) {
|
||||
self.last_content_height = Some(height as usize);
|
||||
}
|
||||
|
||||
fn scroll_area(&self, area: Rect) -> Rect {
|
||||
let mut area = area;
|
||||
area.y = area.y.saturating_add(1);
|
||||
@@ -337,6 +345,24 @@ impl PagerView {
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn is_scrolled_to_bottom(&self) -> bool {
|
||||
if self.scroll_offset == usize::MAX {
|
||||
return true;
|
||||
}
|
||||
let Some(cache) = &self.wrap_cache else {
|
||||
return false;
|
||||
};
|
||||
let Some(height) = self.last_content_height else {
|
||||
return false;
|
||||
};
|
||||
if cache.wrapped.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let visible = height.min(cache.wrapped.len());
|
||||
let max_scroll = cache.wrapped.len().saturating_sub(visible);
|
||||
self.scroll_offset >= max_scroll
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
@@ -359,8 +385,12 @@ impl TranscriptOverlay {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
self.view.lines.extend(lines);
|
||||
self.view.wrap_cache = None;
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
|
||||
@@ -541,6 +571,39 @@ mod tests {
|
||||
assert_snapshot!(term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
|
||||
let mut overlay =
|
||||
TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect());
|
||||
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
|
||||
assert!(
|
||||
overlay.view.is_scrolled_to_bottom(),
|
||||
"expected initial render to leave view at bottom"
|
||||
);
|
||||
|
||||
overlay.insert_lines(vec!["tail".into()]);
|
||||
|
||||
assert_eq!(overlay.view.scroll_offset, usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_manual_scroll_position() {
|
||||
let mut overlay =
|
||||
TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect());
|
||||
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
|
||||
overlay.view.scroll_offset = 0;
|
||||
|
||||
overlay.insert_lines(vec!["tail".into()]);
|
||||
|
||||
assert_eq!(overlay.view.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_overlay_snapshot_basic() {
|
||||
// Prepare a static overlay with a few lines and a title
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: terminal.backend()
|
||||
" Working (0s • Esc to interrupt) "
|
||||
" ↳ first "
|
||||
" ↳ second "
|
||||
" Alt+↑ edit "
|
||||
" ⌥↑ edit "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -14,6 +14,7 @@ use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use textwrap::Options as TwOptions;
|
||||
@@ -130,7 +131,8 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
}
|
||||
}
|
||||
if !self.queued_messages.is_empty() {
|
||||
lines.push(Line::from(vec![" ".into(), "Alt+↑".cyan(), " edit".into()]).dim());
|
||||
let shortcut = key_hint::alt("↑");
|
||||
lines.push(Line::from(vec![" ".into(), shortcut, " edit".into()]).dim());
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
@@ -143,7 +145,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -159,7 +160,7 @@ mod tests {
|
||||
terminal
|
||||
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(terminal.backend());
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -173,7 +174,7 @@ mod tests {
|
||||
terminal
|
||||
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(terminal.backend());
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -188,6 +189,6 @@ mod tests {
|
||||
terminal
|
||||
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(terminal.backend());
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
}
|
||||
|
||||
+777
-777
File diff suppressed because one or more lines are too long
+1
-1
@@ -607,11 +607,11 @@ Options that are specific to the TUI.
|
||||
| `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. |
|
||||
| `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). |
|
||||
| `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. |
|
||||
| `model_reasoning_summary_format` | `none` \| `experimental` | Force reasoning summary format. |
|
||||
| `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. |
|
||||
| `experimental_resume` | string (path) | Resume JSONL path (internal/experimental). |
|
||||
| `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). |
|
||||
| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
|
||||
| `use_experimental_reasoning_summary` | boolean | Use experimental summary for reasoning chain. |
|
||||
| `responses_originator_header_internal_override` | string | Override `originator` header value. |
|
||||
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
|
||||
| `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). |
|
||||
|
||||
Reference in New Issue
Block a user