mirror of
https://github.com/openai/codex.git
synced 2026-02-06 00:43:40 +00:00
Compare commits
10 Commits
easong/fix
...
seratch-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b0cf2382 | ||
|
|
6209d49520 | ||
|
|
c3a8b96a60 | ||
|
|
c9ca63dc1e | ||
|
|
ed06f90fb3 | ||
|
|
f09170b574 | ||
|
|
1e9e703b96 | ||
|
|
74d2741729 | ||
|
|
e5611aab07 | ||
|
|
4e9ad23864 |
49
.github/ISSUE_TEMPLATE/5-vs-code-extension.yml
vendored
Normal file
49
.github/ISSUE_TEMPLATE/5-vs-code-extension.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: 🧑💻 VS Code Extension
|
||||
description: Report an issue with the VS Code extension
|
||||
labels:
|
||||
- codex-ide
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting a new issue, please search for existing issues to see if your issue has already been reported.
|
||||
If it has, please add a 👍 reaction (no need to leave a comment) to the existing issue instead of creating a new one.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: What version of the VS Code extension are you using?
|
||||
- type: input
|
||||
id: ide
|
||||
attributes:
|
||||
label: Which IDE are you using?
|
||||
description: Like `VS Code`, `Cursor`, `Windsurf`, etc.
|
||||
- type: input
|
||||
id: platform
|
||||
attributes:
|
||||
label: What platform is your computer?
|
||||
description: |
|
||||
For MacOS and Linux: copy the output of `uname -mprs`
|
||||
For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: What steps can reproduce the bug?
|
||||
description: Explain the bug and provide a code snippet that can reproduce it.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What is the expected behavior?
|
||||
description: If possible, please provide text instead of a screenshot.
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: What do you see instead?
|
||||
description: If possible, please provide text instead of a screenshot.
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Is there anything else you think we should know?
|
||||
@@ -34,6 +34,7 @@ rust = {}
|
||||
|
||||
[workspace.lints.clippy]
|
||||
expect_used = "deny"
|
||||
uninlined_format_args = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -518,6 +518,7 @@ impl Session {
|
||||
include_apply_patch_tool: config.include_apply_patch_tool,
|
||||
include_web_search_request: config.tools_web_search_request,
|
||||
use_streamable_shell_tool: config.use_experimental_streamable_shell_tool,
|
||||
include_view_image_tool: config.include_view_image_tool,
|
||||
}),
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
@@ -1108,6 +1109,7 @@ async fn submission_loop(
|
||||
include_apply_patch_tool: config.include_apply_patch_tool,
|
||||
include_web_search_request: config.tools_web_search_request,
|
||||
use_streamable_shell_tool: config.use_experimental_streamable_shell_tool,
|
||||
include_view_image_tool: config.include_view_image_tool,
|
||||
});
|
||||
|
||||
let new_turn_context = TurnContext {
|
||||
@@ -1193,6 +1195,7 @@ async fn submission_loop(
|
||||
include_web_search_request: config.tools_web_search_request,
|
||||
use_streamable_shell_tool: config
|
||||
.use_experimental_streamable_shell_tool,
|
||||
include_view_image_tool: config.include_view_image_tool,
|
||||
}),
|
||||
user_instructions: turn_context.user_instructions.clone(),
|
||||
base_instructions: turn_context.base_instructions.clone(),
|
||||
@@ -1881,6 +1884,12 @@ async fn run_compact_task(
|
||||
}
|
||||
|
||||
sess.remove_task(&sub_id);
|
||||
|
||||
{
|
||||
let mut state = sess.state.lock_unchecked();
|
||||
state.history.keep_last_messages(1);
|
||||
}
|
||||
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
@@ -1895,9 +1904,6 @@ async fn run_compact_task(
|
||||
}),
|
||||
};
|
||||
sess.send_event(event).await;
|
||||
|
||||
let mut state = sess.state.lock_unchecked();
|
||||
state.history.keep_last_messages(1);
|
||||
}
|
||||
|
||||
async fn handle_response_item(
|
||||
@@ -2077,6 +2083,36 @@ async fn handle_function_call(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"view_image" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SeeImageArgs {
|
||||
path: String,
|
||||
}
|
||||
let args = match serde_json::from_str::<SeeImageArgs>(&arguments) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("failed to parse function arguments: {e}"),
|
||||
success: Some(false),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
let abs = turn_context.resolve_path(Some(args.path));
|
||||
let output = match sess.inject_input(vec![InputItem::LocalImage { path: abs }]) {
|
||||
Ok(()) => FunctionCallOutputPayload {
|
||||
content: "attached local image path".to_string(),
|
||||
success: Some(true),
|
||||
},
|
||||
Err(_) => FunctionCallOutputPayload {
|
||||
content: "unable to attach image (no active task)".to_string(),
|
||||
success: Some(false),
|
||||
},
|
||||
};
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output }
|
||||
}
|
||||
"apply_patch" => {
|
||||
let args = match serde_json::from_str::<ApplyPatchToolArgs>(&arguments) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -178,6 +178,13 @@ pub struct Config {
|
||||
pub preferred_auth_method: AuthMode,
|
||||
|
||||
pub use_experimental_streamable_shell_tool: bool,
|
||||
|
||||
/// Include the `view_image` tool that lets the agent attach a local image path to context.
|
||||
pub include_view_image_tool: bool,
|
||||
/// When true, disables burst-paste detection for typed input entirely.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -485,6 +492,11 @@ pub struct ConfigToml {
|
||||
|
||||
/// Nested tools section for feature toggles
|
||||
pub tools: Option<ToolsToml>,
|
||||
|
||||
/// When true, disables burst-paste detection for typed input entirely.
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
pub disable_paste_burst: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
@@ -497,6 +509,10 @@ pub struct ToolsToml {
|
||||
// Renamed from `web_search_request`; keep alias for backwards compatibility.
|
||||
#[serde(default, alias = "web_search_request")]
|
||||
pub web_search: Option<bool>,
|
||||
|
||||
/// Enable the `view_image` tool that lets the agent attach local images.
|
||||
#[serde(default)]
|
||||
pub view_image: Option<bool>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
@@ -586,6 +602,7 @@ pub struct ConfigOverrides {
|
||||
pub base_instructions: Option<String>,
|
||||
pub include_plan_tool: Option<bool>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_view_image_tool: Option<bool>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
pub tools_web_search_request: Option<bool>,
|
||||
@@ -613,6 +630,7 @@ impl Config {
|
||||
base_instructions,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_view_image_tool,
|
||||
disable_response_storage,
|
||||
show_raw_agent_reasoning,
|
||||
tools_web_search_request: override_tools_web_search_request,
|
||||
@@ -681,6 +699,10 @@ impl Config {
|
||||
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
|
||||
.unwrap_or(false);
|
||||
|
||||
let include_view_image_tool = include_view_image_tool
|
||||
.or(cfg.tools.as_ref().and_then(|t| t.view_image))
|
||||
.unwrap_or(true);
|
||||
|
||||
let model = model
|
||||
.or(config_profile.model)
|
||||
.or(cfg.model)
|
||||
@@ -784,6 +806,8 @@ impl Config {
|
||||
use_experimental_streamable_shell_tool: cfg
|
||||
.experimental_use_exec_command_tool
|
||||
.unwrap_or(false),
|
||||
include_view_image_tool,
|
||||
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -1152,6 +1176,8 @@ disable_response_storage = true
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -1208,6 +1234,8 @@ disable_response_storage = true
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -1279,6 +1307,8 @@ disable_response_storage = true
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
disable_paste_burst: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -1300,9 +1330,9 @@ disable_response_storage = true
|
||||
|
||||
let raw_path = project_dir.path().to_string_lossy();
|
||||
let path_str = if raw_path.contains('\\') {
|
||||
format!("'{}'", raw_path)
|
||||
format!("'{raw_path}'")
|
||||
} else {
|
||||
format!("\"{}\"", raw_path)
|
||||
format!("\"{raw_path}\"")
|
||||
};
|
||||
let expected = format!(
|
||||
r#"[projects.{path_str}]
|
||||
@@ -1323,9 +1353,9 @@ trust_level = "trusted"
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let raw_path = project_dir.path().to_string_lossy();
|
||||
let path_str = if raw_path.contains('\\') {
|
||||
format!("'{}'", raw_path)
|
||||
format!("'{raw_path}'")
|
||||
} else {
|
||||
format!("\"{}\"", raw_path)
|
||||
format!("\"{raw_path}\"")
|
||||
};
|
||||
// Use a quoted key so backslashes don't require escaping on Windows
|
||||
let initial = format!(
|
||||
|
||||
@@ -85,23 +85,21 @@ impl EnvironmentContext {
|
||||
}
|
||||
if let Some(approval_policy) = self.approval_policy {
|
||||
lines.push(format!(
|
||||
" <approval_policy>{}</approval_policy>",
|
||||
approval_policy
|
||||
" <approval_policy>{approval_policy}</approval_policy>"
|
||||
));
|
||||
}
|
||||
if let Some(sandbox_mode) = self.sandbox_mode {
|
||||
lines.push(format!(" <sandbox_mode>{}</sandbox_mode>", sandbox_mode));
|
||||
lines.push(format!(" <sandbox_mode>{sandbox_mode}</sandbox_mode>"));
|
||||
}
|
||||
if let Some(network_access) = self.network_access {
|
||||
lines.push(format!(
|
||||
" <network_access>{}</network_access>",
|
||||
network_access
|
||||
" <network_access>{network_access}</network_access>"
|
||||
));
|
||||
}
|
||||
if let Some(shell) = self.shell
|
||||
&& let Some(shell_name) = shell.name()
|
||||
{
|
||||
lines.push(format!(" <shell>{}</shell>", shell_name));
|
||||
lines.push(format!(" <shell>{shell_name}</shell>"));
|
||||
}
|
||||
lines.push(ENVIRONMENT_CONTEXT_END.to_string());
|
||||
lines.join("\n")
|
||||
|
||||
@@ -170,15 +170,15 @@ fn format_reset_duration(total_secs: u64) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if days > 0 {
|
||||
let unit = if days == 1 { "day" } else { "days" };
|
||||
parts.push(format!("{} {}", days, unit));
|
||||
parts.push(format!("{days} {unit}"));
|
||||
}
|
||||
if hours > 0 {
|
||||
let unit = if hours == 1 { "hour" } else { "hours" };
|
||||
parts.push(format!("{} {}", hours, unit));
|
||||
parts.push(format!("{hours} {unit}"));
|
||||
}
|
||||
if minutes > 0 {
|
||||
let unit = if minutes == 1 { "minute" } else { "minutes" };
|
||||
parts.push(format!("{} {}", minutes, unit));
|
||||
parts.push(format!("{minutes} {unit}"));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
|
||||
@@ -359,10 +359,7 @@ fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option<u64>) {
|
||||
let est_tokens = (s.len() as u64).div_ceil(4);
|
||||
if max_bytes == 0 {
|
||||
// Cannot keep any content; still return a full marker (never truncated).
|
||||
return (
|
||||
format!("…{} tokens truncated…", est_tokens),
|
||||
Some(est_tokens),
|
||||
);
|
||||
return (format!("…{est_tokens} tokens truncated…"), Some(est_tokens));
|
||||
}
|
||||
|
||||
// Helper to truncate a string to a given byte length on a char boundary.
|
||||
@@ -406,16 +403,13 @@ fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option<u64>) {
|
||||
// Refine marker length and budgets until stable. Marker is never truncated.
|
||||
let mut guess_tokens = est_tokens; // worst-case: everything truncated
|
||||
for _ in 0..4 {
|
||||
let marker = format!("…{} tokens truncated…", guess_tokens);
|
||||
let marker = format!("…{guess_tokens} tokens truncated…");
|
||||
let marker_len = marker.len();
|
||||
let keep_budget = max_bytes.saturating_sub(marker_len);
|
||||
if keep_budget == 0 {
|
||||
// No room for any content within the cap; return a full, untruncated marker
|
||||
// that reflects the entire truncated content.
|
||||
return (
|
||||
format!("…{} tokens truncated…", est_tokens),
|
||||
Some(est_tokens),
|
||||
);
|
||||
return (format!("…{est_tokens} tokens truncated…"), Some(est_tokens));
|
||||
}
|
||||
|
||||
let left_budget = keep_budget / 2;
|
||||
@@ -441,14 +435,11 @@ fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option<u64>) {
|
||||
}
|
||||
|
||||
// Fallback: use last guess to build output.
|
||||
let marker = format!("…{} tokens truncated…", guess_tokens);
|
||||
let marker = format!("…{guess_tokens} tokens truncated…");
|
||||
let marker_len = marker.len();
|
||||
let keep_budget = max_bytes.saturating_sub(marker_len);
|
||||
if keep_budget == 0 {
|
||||
return (
|
||||
format!("…{} tokens truncated…", est_tokens),
|
||||
Some(est_tokens),
|
||||
);
|
||||
return (format!("…{est_tokens} tokens truncated…"), Some(est_tokens));
|
||||
}
|
||||
let left_budget = keep_budget / 2;
|
||||
let right_budget = keep_budget - left_budget;
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub plan_tool: bool,
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub web_search_request: bool,
|
||||
pub include_view_image_tool: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolsConfigParams<'a> {
|
||||
@@ -77,6 +78,7 @@ pub(crate) struct ToolsConfigParams<'a> {
|
||||
pub(crate) include_apply_patch_tool: bool,
|
||||
pub(crate) include_web_search_request: bool,
|
||||
pub(crate) use_streamable_shell_tool: bool,
|
||||
pub(crate) include_view_image_tool: bool,
|
||||
}
|
||||
|
||||
impl ToolsConfig {
|
||||
@@ -89,6 +91,7 @@ impl ToolsConfig {
|
||||
include_apply_patch_tool,
|
||||
include_web_search_request,
|
||||
use_streamable_shell_tool,
|
||||
include_view_image_tool,
|
||||
} = params;
|
||||
let mut shell_type = if *use_streamable_shell_tool {
|
||||
ConfigShellToolType::StreamableShell
|
||||
@@ -120,6 +123,7 @@ impl ToolsConfig {
|
||||
plan_tool: *include_plan_tool,
|
||||
apply_patch_tool_type,
|
||||
web_search_request: *include_web_search_request,
|
||||
include_view_image_tool: *include_view_image_tool,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,6 +296,30 @@ The shell tool is used to execute shell commands.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_view_image_tool() -> OpenAiTool {
|
||||
// Support only local filesystem path.
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Local filesystem path to an image file".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "view_image".to_string(),
|
||||
description:
|
||||
"Attach a local image (by filesystem path) to the conversation context for this turn."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
/// TODO(dylan): deprecate once we get rid of json tool
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct ApplyPatchToolArgs {
|
||||
@@ -541,6 +569,11 @@ pub(crate) fn get_openai_tools(
|
||||
tools.push(OpenAiTool::WebSearch {});
|
||||
}
|
||||
|
||||
// Include the view_image tool so the agent can attach images to context.
|
||||
if config.include_view_image_tool {
|
||||
tools.push(create_view_image_tool());
|
||||
}
|
||||
|
||||
if let Some(mcp_tools) = mcp_tools {
|
||||
// Ensure deterministic ordering to maximize prompt cache hits.
|
||||
// HashMap iteration order is non-deterministic, so sort by fully-qualified tool name.
|
||||
@@ -604,10 +637,14 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
|
||||
assert_eq_tool_names(&tools, &["local_shell", "update_plan", "web_search"]);
|
||||
assert_eq_tool_names(
|
||||
&tools,
|
||||
&["local_shell", "update_plan", "web_search", "view_image"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -621,10 +658,14 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "update_plan", "web_search"]);
|
||||
assert_eq_tool_names(
|
||||
&tools,
|
||||
&["shell", "update_plan", "web_search", "view_image"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -638,6 +679,7 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
@@ -679,11 +721,16 @@ mod tests {
|
||||
|
||||
assert_eq_tool_names(
|
||||
&tools,
|
||||
&["shell", "web_search", "test_server/do_something_cool"],
|
||||
&[
|
||||
"shell",
|
||||
"web_search",
|
||||
"view_image",
|
||||
"test_server/do_something_cool",
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
tools[2],
|
||||
tools[3],
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "test_server/do_something_cool".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
@@ -737,6 +784,7 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: false,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
|
||||
// Intentionally construct a map with keys that would sort alphabetically.
|
||||
@@ -794,6 +842,7 @@ mod tests {
|
||||
&tools,
|
||||
&[
|
||||
"shell",
|
||||
"view_image",
|
||||
"test_server/cool",
|
||||
"test_server/do",
|
||||
"test_server/something",
|
||||
@@ -812,6 +861,7 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -837,10 +887,13 @@ mod tests {
|
||||
)])),
|
||||
);
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "web_search", "dash/search"]);
|
||||
assert_eq_tool_names(
|
||||
&tools,
|
||||
&["shell", "web_search", "view_image", "dash/search"],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
tools[2],
|
||||
tools[3],
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "dash/search".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
@@ -870,6 +923,7 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -893,9 +947,12 @@ mod tests {
|
||||
)])),
|
||||
);
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "web_search", "dash/paginate"]);
|
||||
assert_eq_tool_names(
|
||||
&tools,
|
||||
&["shell", "web_search", "view_image", "dash/paginate"],
|
||||
);
|
||||
assert_eq!(
|
||||
tools[2],
|
||||
tools[3],
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "dash/paginate".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
@@ -923,6 +980,7 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -946,9 +1004,9 @@ mod tests {
|
||||
)])),
|
||||
);
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "web_search", "dash/tags"]);
|
||||
assert_eq_tool_names(&tools, &["shell", "web_search", "view_image", "dash/tags"]);
|
||||
assert_eq!(
|
||||
tools[2],
|
||||
tools[3],
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "dash/tags".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
@@ -979,6 +1037,7 @@ mod tests {
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
include_view_image_tool: true,
|
||||
});
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -1002,9 +1061,9 @@ mod tests {
|
||||
)])),
|
||||
);
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "web_search", "dash/value"]);
|
||||
assert_eq_tool_names(&tools, &["shell", "web_search", "view_image", "dash/value"]);
|
||||
assert_eq!(
|
||||
tools[2],
|
||||
tools[3],
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "dash/value".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
|
||||
@@ -418,7 +418,7 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
|
||||
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
|
||||
Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {}", e),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {e}"),
|
||||
};
|
||||
let conversation_manager = ConversationManager::new(auth_manager);
|
||||
let NewConversation {
|
||||
@@ -499,7 +499,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
|
||||
Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {}", e),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {e}"),
|
||||
};
|
||||
let conversation_manager = ConversationManager::new(auth_manager);
|
||||
let NewConversation {
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn prompt_tools_are_consistent_across_requests() {
|
||||
let expected_instructions: &str = include_str!("../../prompt.md");
|
||||
// our internal implementation is responsible for keeping tools in sync
|
||||
// with the OpenAI schema, so we just verify the tool presence here
|
||||
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch"];
|
||||
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch", "view_image"];
|
||||
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body0["instructions"],
|
||||
@@ -280,7 +280,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
{}</environment_context>"#,
|
||||
cwd.path().to_string_lossy(),
|
||||
match shell.name() {
|
||||
Some(name) => format!(" <shell>{}</shell>\n", name),
|
||||
Some(name) => format!(" <shell>{name}</shell>\n"),
|
||||
None => String::new(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -148,6 +148,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
base_instructions: None,
|
||||
include_plan_tool: None,
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: oss.then_some(true),
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
|
||||
@@ -28,7 +28,7 @@ impl Respond for SeqResponder {
|
||||
Some(body) => wiremock::ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(
|
||||
load_sse_fixture_with_id_from_str(body, &format!("request_{}", call_num)),
|
||||
load_sse_fixture_with_id_from_str(body, &format!("request_{call_num}")),
|
||||
"text/event-stream",
|
||||
),
|
||||
None => panic!("no response for {call_num}"),
|
||||
@@ -63,7 +63,7 @@ pub(crate) async fn run_e2e_exec_test(cwd: &Path, response_streams: Vec<String>)
|
||||
.current_dir(cwd.clone())
|
||||
.env("CODEX_HOME", cwd.clone())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("OPENAI_BASE_URL", format!("{}/v1", uri))
|
||||
.env("OPENAI_BASE_URL", format!("{uri}/v1"))
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-s")
|
||||
.arg("danger-full-access")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Sign into Codex CLI</title>
|
||||
<title>Sign into Codex</title>
|
||||
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
|
||||
<style>
|
||||
.container {
|
||||
@@ -135,7 +135,7 @@
|
||||
<div class="logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
|
||||
</div>
|
||||
<div class="title">Signed in to Codex CLI</div>
|
||||
<div class="title">Signed in to Codex</div>
|
||||
</div>
|
||||
<div class="close-box" style="display: none;">
|
||||
<div class="setup-description">You may now close this page</div>
|
||||
|
||||
@@ -798,6 +798,7 @@ fn derive_config_from_params(
|
||||
base_instructions,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
tools_web_search_request: None,
|
||||
|
||||
@@ -161,6 +161,7 @@ impl CodexToolCallParam {
|
||||
base_instructions,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
tools_web_search_request: None,
|
||||
|
||||
@@ -123,7 +123,7 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
|
||||
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
|
||||
let method = format!("codex/event/{}", notification);
|
||||
let method = format!("codex/event/{notification}");
|
||||
let params = match serde_json::to_value(¬ification) {
|
||||
Ok(serde_json::Value::Object(mut map)) => map.remove("data"),
|
||||
_ => None,
|
||||
|
||||
@@ -61,6 +61,7 @@ impl McpProcess {
|
||||
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.env("CODEX_HOME", codex_home);
|
||||
cmd.env("RUST_LOG", "debug");
|
||||
|
||||
@@ -77,6 +78,17 @@ impl McpProcess {
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::format_err!("mcp should have stdout fd"))?;
|
||||
let stdout = BufReader::new(stdout);
|
||||
|
||||
// Forward child's stderr to our stderr so failures are visible even
|
||||
// when stdout/stderr are captured by the test harness.
|
||||
if let Some(stderr) = process.stderr.take() {
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = stderr_reader.next_line().await {
|
||||
eprintln!("[mcp stderr] {line}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
next_request_id: AtomicI64::new(0),
|
||||
process,
|
||||
@@ -283,6 +295,7 @@ impl McpProcess {
|
||||
}
|
||||
|
||||
async fn send_jsonrpc_message(&mut self, message: JSONRPCMessage) -> anyhow::Result<()> {
|
||||
eprintln!("writing message to stdin: {message:?}");
|
||||
let payload = serde_json::to_string(&message)?;
|
||||
self.stdin.write_all(payload.as_bytes()).await?;
|
||||
self.stdin.write_all(b"\n").await?;
|
||||
@@ -294,13 +307,15 @@ impl McpProcess {
|
||||
let mut line = String::new();
|
||||
self.stdout.read_line(&mut line).await?;
|
||||
let message = serde_json::from_str::<JSONRPCMessage>(&line)?;
|
||||
eprintln!("read message from stdout: {message:?}");
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<JSONRPCRequest> {
|
||||
eprintln!("in read_stream_until_request_message()");
|
||||
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(_) => {
|
||||
@@ -323,10 +338,10 @@ impl McpProcess {
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
) -> anyhow::Result<JSONRPCResponse> {
|
||||
eprintln!("in read_stream_until_response_message({request_id:?})");
|
||||
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(_) => {
|
||||
eprintln!("notification: {message:?}");
|
||||
@@ -352,8 +367,6 @@ impl McpProcess {
|
||||
) -> anyhow::Result<mcp_types::JSONRPCError> {
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(_) => {
|
||||
eprintln!("notification: {message:?}");
|
||||
@@ -377,10 +390,10 @@ impl McpProcess {
|
||||
&mut self,
|
||||
method: &str,
|
||||
) -> anyhow::Result<JSONRPCNotification> {
|
||||
eprintln!("in read_stream_until_notification_message({method})");
|
||||
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
if notification.method == method {
|
||||
@@ -405,10 +418,10 @@ impl McpProcess {
|
||||
pub async fn read_stream_until_legacy_task_complete_notification(
|
||||
&mut self,
|
||||
) -> anyhow::Result<JSONRPCNotification> {
|
||||
eprintln!("in read_stream_until_legacy_task_complete_notification()");
|
||||
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
let is_match = if notification.method == "codex/event" {
|
||||
@@ -427,6 +440,8 @@ impl McpProcess {
|
||||
|
||||
if is_match {
|
||||
return Ok(notification);
|
||||
} else {
|
||||
eprintln!("ignoring notification: {notification:?}");
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Request(_) => {
|
||||
|
||||
@@ -30,7 +30,8 @@ use mcp_test_support::create_final_assistant_message_sse_response;
|
||||
use mcp_test_support::create_mock_chat_completions_server;
|
||||
use mcp_test_support::create_shell_sse_response;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
// Allow ample time on slower CI or under load to avoid flakes.
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Test that a shell command that is not on the "trusted" list triggers an
|
||||
/// elicitation request to the MCP and that sending the approval runs the
|
||||
@@ -52,9 +53,22 @@ async fn test_shell_command_approval_triggers_elicitation() {
|
||||
}
|
||||
|
||||
async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
// We use `git init` because it will not be on the "trusted" list.
|
||||
let shell_command = vec!["git".to_string(), "init".to_string()];
|
||||
// Use a simple, untrusted command that creates a file so we can
|
||||
// observe a side-effect.
|
||||
//
|
||||
// Cross‑platform approach: run a tiny Python snippet to touch the file
|
||||
// using `python3 -c ...` on all platforms.
|
||||
let workdir_for_shell_function_call = TempDir::new()?;
|
||||
let created_filename = "created_by_shell_tool.txt";
|
||||
let created_file = workdir_for_shell_function_call
|
||||
.path()
|
||||
.join(created_filename);
|
||||
|
||||
let shell_command = vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("import pathlib; pathlib.Path('{created_filename}').touch()"),
|
||||
];
|
||||
|
||||
let McpHandle {
|
||||
process: mut mcp_process,
|
||||
@@ -67,7 +81,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
Some(5_000),
|
||||
"call1234",
|
||||
)?,
|
||||
create_final_assistant_message_sse_response("Enjoy your new git repo!")?,
|
||||
create_final_assistant_message_sse_response("File created!")?,
|
||||
])
|
||||
.await?;
|
||||
|
||||
@@ -122,8 +136,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
.expect("task_complete_notification timeout")
|
||||
.expect("task_complete_notification resp");
|
||||
|
||||
// Verify the original `codex` tool call completes and that `git init` ran
|
||||
// successfully.
|
||||
// Verify the original `codex` tool call completes and that the file was created.
|
||||
let codex_response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
|
||||
@@ -136,7 +149,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
result: json!({
|
||||
"content": [
|
||||
{
|
||||
"text": "Enjoy your new git repo!",
|
||||
"text": "File created!",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
@@ -145,10 +158,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
codex_response
|
||||
);
|
||||
|
||||
assert!(
|
||||
workdir_for_shell_function_call.path().join(".git").is_dir(),
|
||||
".git folder should have been created"
|
||||
);
|
||||
assert!(created_file.is_file(), "created file should exist");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -133,6 +133,12 @@ impl App {
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if self
|
||||
.chat_widget
|
||||
.handle_paste_burst_tick(tui.frame_requester())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
tui.draw(
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||
|frame| {
|
||||
|
||||
@@ -100,6 +100,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
|
||||
assert!(view.queue.is_empty());
|
||||
|
||||
@@ -24,6 +24,8 @@ use ratatui::widgets::WidgetRef;
|
||||
use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::paste_burst::CharDecision;
|
||||
use super::paste_burst::PasteBurst;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
@@ -40,11 +42,6 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
// Heuristic thresholds for detecting paste-like input bursts.
|
||||
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
||||
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
||||
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
||||
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
@@ -93,13 +90,10 @@ pub(crate) struct ChatComposer {
|
||||
has_focus: bool,
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
// Heuristic state to detect non-bracketed paste bursts.
|
||||
last_plain_char_time: Option<Instant>,
|
||||
consecutive_plain_char_burst: u16,
|
||||
paste_burst_until: Option<Instant>,
|
||||
// Buffer to accumulate characters during a detected non-bracketed paste burst.
|
||||
paste_burst_buffer: String,
|
||||
in_paste_burst_mode: bool,
|
||||
// Non-bracketed paste burst tracker.
|
||||
paste_burst: PasteBurst,
|
||||
// When true, disables paste-burst logic and inserts characters immediately.
|
||||
disable_paste_burst: bool,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -115,10 +109,11 @@ impl ChatComposer {
|
||||
app_event_tx: AppEventSender,
|
||||
enhanced_keys_supported: bool,
|
||||
placeholder_text: String,
|
||||
disable_paste_burst: bool,
|
||||
) -> Self {
|
||||
let use_shift_enter_hint = enhanced_keys_supported;
|
||||
|
||||
Self {
|
||||
let mut this = Self {
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
active_popup: ActivePopup::None,
|
||||
@@ -134,12 +129,12 @@ impl ChatComposer {
|
||||
has_focus: has_input_focus,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
last_plain_char_time: None,
|
||||
consecutive_plain_char_burst: 0,
|
||||
paste_burst_until: None,
|
||||
paste_burst_buffer: String::new(),
|
||||
in_paste_burst_mode: false,
|
||||
}
|
||||
paste_burst: PasteBurst::default(),
|
||||
disable_paste_burst: false,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
this
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
@@ -229,11 +224,15 @@ impl ChatComposer {
|
||||
self.textarea.insert_str(&pasted);
|
||||
}
|
||||
// Explicit paste events should not trigger Enter suppression.
|
||||
self.last_plain_char_time = None;
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.paste_burst_until = None;
|
||||
self.paste_burst.clear_after_explicit_paste();
|
||||
// Keep popup sync consistent with key handling: prefer slash popup; only
|
||||
// sync file popup when slash popup is NOT active.
|
||||
self.sync_command_popup();
|
||||
self.sync_file_search_popup();
|
||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||
self.dismissed_file_popup_token = None;
|
||||
} else {
|
||||
self.sync_file_search_popup();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -256,6 +255,14 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
||||
let was_disabled = self.disable_paste_burst;
|
||||
self.disable_paste_burst = disabled;
|
||||
if disabled && !was_disabled {
|
||||
self.paste_burst.clear_window_after_non_char();
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the entire composer content with `text` and reset cursor.
|
||||
pub(crate) fn set_text_content(&mut self, text: String) {
|
||||
self.textarea.set_text(&text);
|
||||
@@ -270,6 +277,7 @@ impl ChatComposer {
|
||||
self.textarea.text().to_string()
|
||||
}
|
||||
|
||||
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
||||
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
|
||||
let placeholder = format!("[image {width}x{height} {format_label}]");
|
||||
// Insert as an element to match large paste placeholder behavior:
|
||||
@@ -284,6 +292,23 @@ impl ChatComposer {
|
||||
images.into_iter().map(|img| img.path).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
|
||||
let _ = self.handle_paste(pasted);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||
self.paste_burst.is_active()
|
||||
}
|
||||
|
||||
pub(crate) fn recommended_paste_flush_delay() -> Duration {
|
||||
PasteBurst::recommended_flush_delay()
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -423,9 +448,7 @@ impl ChatComposer {
|
||||
|
||||
#[inline]
|
||||
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
|
||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
||||
self.in_paste_burst_mode = false;
|
||||
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
self.textarea.input(input);
|
||||
@@ -740,14 +763,11 @@ impl ChatComposer {
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.paste_burst_buffer.push('\n');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
// Keep the window alive so subsequent lines are captured too.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
// If we have pending placeholder pastes, submit immediately to expand them.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
@@ -768,19 +788,12 @@ impl ChatComposer {
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
let tight_after_char = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL);
|
||||
let recent_after_char = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) <= PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
let burst_by_count =
|
||||
recent_after_char && self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS;
|
||||
let in_burst_window = self.paste_burst_until.is_some_and(|until| now <= until);
|
||||
|
||||
if tight_after_char || burst_by_count || in_burst_window {
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
@@ -810,22 +823,16 @@ impl ChatComposer {
|
||||
// If we have a buffered non-bracketed paste burst and enough time has
|
||||
// elapsed since the last char, flush it before handling a new input.
|
||||
let now = Instant::now();
|
||||
let timed_out = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
||||
if timed_out && (!self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode) {
|
||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
||||
self.in_paste_burst_mode = false;
|
||||
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
|
||||
// Reuse normal paste path (handles large-paste placeholders).
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
|
||||
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
||||
if matches!(input.code, KeyCode::Enter)
|
||||
&& (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
|
||||
&& self.paste_burst.is_active()
|
||||
&& self.paste_burst.append_newline_if_active(now)
|
||||
{
|
||||
self.paste_burst_buffer.push('\n');
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
@@ -840,65 +847,50 @@ impl ChatComposer {
|
||||
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
|
||||
if !has_ctrl_or_alt {
|
||||
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
|
||||
// misclassified by our non-bracketed paste heuristic. To avoid leaving
|
||||
// residual buffered content or misdetecting a paste, flush any burst buffer
|
||||
// and insert non-ASCII characters directly.
|
||||
// misclassified by paste heuristics. Flush any active burst buffer and insert
|
||||
// non-ASCII characters directly.
|
||||
if !ch.is_ascii() {
|
||||
return self.handle_non_ascii_char(input);
|
||||
}
|
||||
// Update burst heuristics.
|
||||
match self.last_plain_char_time {
|
||||
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
||||
self.consecutive_plain_char_burst =
|
||||
self.consecutive_plain_char_burst.saturating_add(1);
|
||||
}
|
||||
_ => {
|
||||
self.consecutive_plain_char_burst = 1;
|
||||
}
|
||||
}
|
||||
self.last_plain_char_time = Some(now);
|
||||
|
||||
// If we're already buffering, capture the char into the buffer.
|
||||
if self.in_paste_burst_mode {
|
||||
self.paste_burst_buffer.push(ch);
|
||||
// Keep the window alive while we receive the burst.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
} else if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
||||
// Do not start burst buffering while typing a slash command (first line starts with '/').
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if first_line.starts_with('/') {
|
||||
// Keep heuristics but do not buffer.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
// Insert normally.
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
match self.paste_burst.on_plain_char(ch, now) {
|
||||
CharDecision::BufferAppend => {
|
||||
self.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
CharDecision::BeginBuffer { retro_chars } => {
|
||||
let cur = self.textarea.cursor();
|
||||
let txt = self.textarea.text();
|
||||
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
||||
let before = &txt[..safe_cur];
|
||||
if let Some(grab) =
|
||||
self.paste_burst
|
||||
.decide_begin_buffer(now, before, retro_chars as usize)
|
||||
{
|
||||
if !grab.grabbed.is_empty() {
|
||||
self.textarea.replace_range(grab.start_byte..safe_cur, "");
|
||||
}
|
||||
self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now);
|
||||
self.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// If decide_begin_buffer opted not to start buffering,
|
||||
// fall through to normal insertion below.
|
||||
}
|
||||
CharDecision::BeginBufferFromPending => {
|
||||
// First char was held; now append the current one.
|
||||
self.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
CharDecision::RetainFirstChar => {
|
||||
// Keep the first fast char pending momentarily.
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// Begin buffering from this character onward.
|
||||
self.paste_burst_buffer.push(ch);
|
||||
self.in_paste_burst_mode = true;
|
||||
// Keep the window alive to continue capturing.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Not buffering: insert normally and continue.
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
return (InputResult::None, true);
|
||||
} else {
|
||||
// Modified char ends any burst: flush buffered content before applying.
|
||||
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
|
||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
||||
self.in_paste_burst_mode = false;
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
}
|
||||
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
}
|
||||
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
@@ -925,25 +917,15 @@ impl ChatComposer {
|
||||
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| modifiers.contains(KeyModifiers::ALT);
|
||||
if has_ctrl_or_alt {
|
||||
// Modified char: clear burst window.
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.last_plain_char_time = None;
|
||||
self.paste_burst_until = None;
|
||||
self.in_paste_burst_mode = false;
|
||||
self.paste_burst_buffer.clear();
|
||||
self.paste_burst.clear_window_after_non_char();
|
||||
}
|
||||
// Plain chars handled above.
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Keep burst window alive (supports blank lines in paste).
|
||||
}
|
||||
_ => {
|
||||
// Other keys: clear burst window and any buffer (after flushing earlier).
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.last_plain_char_time = None;
|
||||
self.paste_burst_until = None;
|
||||
self.in_paste_burst_mode = false;
|
||||
// Do not clear paste_burst_buffer here; it should have been flushed above.
|
||||
// Other keys: clear burst window (buffer should have been flushed above if needed).
|
||||
self.paste_burst.clear_window_after_non_char();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,8 +1462,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let needs_redraw = composer.handle_paste("hello".to_string());
|
||||
assert!(needs_redraw);
|
||||
@@ -1504,8 +1491,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||||
let needs_redraw = composer.handle_paste(large.clone());
|
||||
@@ -1534,8 +1526,13 @@ mod tests {
|
||||
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.handle_paste(large);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
@@ -1576,6 +1573,7 @@ mod tests {
|
||||
sender.clone(),
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
if let Some(text) = input {
|
||||
@@ -1605,6 +1603,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
|
||||
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
for &ch in chars {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||
let _ = composer.flush_paste_burst_if_due();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -1613,15 +1623,16 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Type the slash command.
|
||||
for ch in [
|
||||
'/', 'i', 'n', 'i', 't', // "/init"
|
||||
] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
|
||||
|
||||
// Press Enter to dispatch the selected command.
|
||||
let (result, _needs_redraw) =
|
||||
@@ -1649,12 +1660,15 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
for ch in ['/', 'c'] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
type_chars_humanlike(&mut composer, &['/', 'c']);
|
||||
|
||||
let (_result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
@@ -1671,12 +1685,15 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
@@ -1703,8 +1720,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Define test cases: (paste content, is_large)
|
||||
let test_cases = [
|
||||
@@ -1777,8 +1799,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Define test cases: (content, is_large)
|
||||
let test_cases = [
|
||||
@@ -1844,8 +1871,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
||||
let test_cases = [
|
||||
@@ -1887,8 +1919,13 @@ mod tests {
|
||||
fn attach_image_and_submit_includes_image_paths() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
composer.handle_paste(" hi".into());
|
||||
@@ -1906,8 +1943,13 @@ mod tests {
|
||||
fn attach_image_without_text_submits_empty_text_and_images() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
let (result, _) =
|
||||
@@ -1926,8 +1968,13 @@ mod tests {
|
||||
fn image_placeholder_backspace_behaves_like_text_placeholder() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
@@ -1962,8 +2009,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Insert an image placeholder at the start
|
||||
let path = PathBuf::from("/tmp/image_multibyte.png");
|
||||
@@ -1983,8 +2035,13 @@ mod tests {
|
||||
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||
@@ -2025,8 +2082,13 @@ mod tests {
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||
assert!(needs_redraw);
|
||||
@@ -2035,4 +2097,104 @@ mod tests {
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(imgs, vec![tmp_path.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let count = 32;
|
||||
for _ in 0..count {
|
||||
let _ =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
composer.is_in_paste_burst(),
|
||||
"expected active paste burst during fast typing"
|
||||
);
|
||||
assert!(
|
||||
composer.textarea.text().is_empty(),
|
||||
"text should not appear during burst"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
composer.textarea.text().is_empty(),
|
||||
"text should remain empty until flush"
|
||||
);
|
||||
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||
let flushed = composer.flush_paste_burst_if_due();
|
||||
assert!(flushed, "expected buffered text to flush after stop");
|
||||
assert_eq!(composer.textarea.text(), "a".repeat(count));
|
||||
assert!(
|
||||
composer.pending_pastes.is_empty(),
|
||||
"no placeholder for small burst"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
|
||||
for _ in 0..count {
|
||||
let _ =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
// Nothing should appear until we stop and flush
|
||||
assert!(composer.textarea.text().is_empty());
|
||||
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||
let flushed = composer.flush_paste_burst_if_due();
|
||||
assert!(flushed, "expected flush after stopping fast input");
|
||||
|
||||
let expected_placeholder = format!("[Pasted Content {count} chars]");
|
||||
assert_eq!(composer.textarea.text(), expected_placeholder);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
assert_eq!(composer.pending_pastes[0].0, expected_placeholder);
|
||||
assert_eq!(composer.pending_pastes[0].1.len(), count);
|
||||
assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
|
||||
let chars: Vec<char> = vec!['z'; count];
|
||||
type_chars_humanlike(&mut composer, &chars);
|
||||
|
||||
assert_eq!(composer.textarea.text(), "z".repeat(count));
|
||||
assert!(composer.pending_pastes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_modal_view;
|
||||
mod bottom_pane_view;
|
||||
@@ -21,6 +22,7 @@ mod chat_composer_history;
|
||||
mod command_popup;
|
||||
mod file_search_popup;
|
||||
mod list_selection_view;
|
||||
mod paste_burst;
|
||||
mod popup_consts;
|
||||
mod scroll_state;
|
||||
mod selection_popup_common;
|
||||
@@ -69,6 +71,7 @@ pub(crate) struct BottomPaneParams {
|
||||
pub(crate) has_input_focus: bool,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) placeholder_text: String,
|
||||
pub(crate) disable_paste_burst: bool,
|
||||
}
|
||||
|
||||
impl BottomPane {
|
||||
@@ -81,6 +84,7 @@ impl BottomPane {
|
||||
params.app_event_tx.clone(),
|
||||
enhanced_keys_supported,
|
||||
params.placeholder_text,
|
||||
params.disable_paste_burst,
|
||||
),
|
||||
active_view: None,
|
||||
app_event_tx: params.app_event_tx,
|
||||
@@ -182,6 +186,9 @@ impl BottomPane {
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
if self.composer.is_in_paste_burst() {
|
||||
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
|
||||
}
|
||||
input_result
|
||||
}
|
||||
}
|
||||
@@ -382,12 +389,24 @@ impl BottomPane {
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn request_redraw_in(&self, dur: Duration) {
|
||||
self.frame_requester.schedule_frame_in(dur);
|
||||
}
|
||||
|
||||
// --- History helpers ---
|
||||
|
||||
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||||
self.composer.set_history_metadata(log_id, entry_count);
|
||||
}
|
||||
|
||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
self.composer.flush_paste_burst_if_due()
|
||||
}
|
||||
|
||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||
self.composer.is_in_paste_burst()
|
||||
}
|
||||
|
||||
pub(crate) fn on_history_entry_response(
|
||||
&mut self,
|
||||
log_id: u64,
|
||||
@@ -473,6 +492,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
pane.push_approval_request(exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
@@ -492,6 +512,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Create an approval modal (active view).
|
||||
@@ -522,6 +543,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Start a running task so the status indicator is active above the composer.
|
||||
@@ -589,6 +611,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Begin a task: show initial status.
|
||||
@@ -619,6 +642,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Activate spinner (status view replaces composer) with no live ring.
|
||||
@@ -669,6 +693,7 @@ mod tests {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
246
codex-rs/tui/src/bottom_pane/paste_burst.rs
Normal file
246
codex-rs/tui/src/bottom_pane/paste_burst.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
// Heuristic thresholds for detecting paste-like input bursts.
|
||||
// Detect quickly to avoid showing typed prefix before paste is recognized
|
||||
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
||||
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
||||
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PasteBurst {
|
||||
last_plain_char_time: Option<Instant>,
|
||||
consecutive_plain_char_burst: u16,
|
||||
burst_window_until: Option<Instant>,
|
||||
buffer: String,
|
||||
active: bool,
|
||||
// Hold first fast char briefly to avoid rendering flicker
|
||||
pending_first_char: Option<(char, Instant)>,
|
||||
}
|
||||
|
||||
pub(crate) enum CharDecision {
|
||||
/// Start buffering and retroactively capture some already-inserted chars.
|
||||
BeginBuffer { retro_chars: u16 },
|
||||
/// We are currently buffering; append the current char into the buffer.
|
||||
BufferAppend,
|
||||
/// Do not insert/render this char yet; temporarily save the first fast
|
||||
/// char while we wait to see if a paste-like burst follows.
|
||||
RetainFirstChar,
|
||||
/// Begin buffering using the previously saved first char (no retro grab needed).
|
||||
BeginBufferFromPending,
|
||||
}
|
||||
|
||||
pub(crate) struct RetroGrab {
|
||||
pub start_byte: usize,
|
||||
pub grabbed: String,
|
||||
}
|
||||
|
||||
impl PasteBurst {
|
||||
/// Recommended delay to wait between simulated keypresses (or before
|
||||
/// scheduling a UI tick) so that a pending fast keystroke is flushed
|
||||
/// out of the burst detector as normal typed input.
|
||||
///
|
||||
/// Primarily used by tests and by the TUI to reliably cross the
|
||||
/// paste-burst timing threshold.
|
||||
pub fn recommended_flush_delay() -> Duration {
|
||||
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
|
||||
}
|
||||
|
||||
/// Entry point: decide how to treat a plain char with current timing.
|
||||
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
|
||||
match self.last_plain_char_time {
|
||||
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
||||
self.consecutive_plain_char_burst =
|
||||
self.consecutive_plain_char_burst.saturating_add(1)
|
||||
}
|
||||
_ => self.consecutive_plain_char_burst = 1,
|
||||
}
|
||||
self.last_plain_char_time = Some(now);
|
||||
|
||||
if self.active {
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return CharDecision::BufferAppend;
|
||||
}
|
||||
|
||||
// If we already held a first char and receive a second fast char,
|
||||
// start buffering without retro-grabbing (we never rendered the first).
|
||||
if let Some((held, held_at)) = self.pending_first_char
|
||||
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
|
||||
{
|
||||
self.active = true;
|
||||
// take() to clear pending; we already captured the held char above
|
||||
let _ = self.pending_first_char.take();
|
||||
self.buffer.push(held);
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return CharDecision::BeginBufferFromPending;
|
||||
}
|
||||
|
||||
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
||||
return CharDecision::BeginBuffer {
|
||||
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
|
||||
};
|
||||
}
|
||||
|
||||
// Save the first fast char very briefly to see if a burst follows.
|
||||
self.pending_first_char = Some((ch, now));
|
||||
CharDecision::RetainFirstChar
|
||||
}
|
||||
|
||||
/// Flush the buffered burst if the inter-key timeout has elapsed.
|
||||
///
|
||||
/// Returns Some(String) when either:
|
||||
/// - We were actively buffering paste-like input and the buffer is now
|
||||
/// emitted as a single pasted string; or
|
||||
/// - We had saved a single fast first-char with no subsequent burst and we
|
||||
/// now emit that char as normal typed input.
|
||||
///
|
||||
/// Returns None if the timeout has not elapsed or there is nothing to flush.
|
||||
pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
|
||||
let timed_out = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
||||
if timed_out && self.is_active_internal() {
|
||||
self.active = false;
|
||||
let out = std::mem::take(&mut self.buffer);
|
||||
Some(out)
|
||||
} else if timed_out {
|
||||
// If we were saving a single fast char and no burst followed,
|
||||
// flush it as normal typed input.
|
||||
if let Some((ch, _at)) = self.pending_first_char.take() {
|
||||
Some(ch.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// While bursting: accumulate a newline into the buffer instead of
|
||||
/// submitting the textarea.
|
||||
///
|
||||
/// Returns true if a newline was appended (we are in a burst context),
|
||||
/// false otherwise.
|
||||
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
|
||||
if self.is_active() {
|
||||
self.buffer.push('\n');
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide if Enter should insert a newline (burst context) vs submit.
|
||||
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
|
||||
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
|
||||
self.is_active() || in_burst_window
|
||||
}
|
||||
|
||||
/// Keep the burst window alive.
|
||||
pub fn extend_window(&mut self, now: Instant) {
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
/// Begin buffering with retroactively grabbed text.
|
||||
pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
|
||||
if !grabbed.is_empty() {
|
||||
self.buffer.push_str(&grabbed);
|
||||
}
|
||||
self.active = true;
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
/// Append a char into the burst buffer.
|
||||
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
|
||||
self.buffer.push(ch);
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
/// Decide whether to begin buffering by retroactively capturing recent
|
||||
/// chars from the slice before the cursor.
|
||||
///
|
||||
/// Heuristic: if the retro-grabbed slice contains any whitespace or is
|
||||
/// sufficiently long (>= 16 characters), treat it as paste-like to avoid
|
||||
/// rendering the typed prefix momentarily before the paste is recognized.
|
||||
/// This favors responsiveness and prevents flicker for typical pastes
|
||||
/// (URLs, file paths, multiline text) while not triggering on short words.
|
||||
///
|
||||
/// Returns Some(RetroGrab) with the start byte and grabbed text when we
|
||||
/// decide to buffer retroactively; otherwise None.
|
||||
pub fn decide_begin_buffer(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
before: &str,
|
||||
retro_chars: usize,
|
||||
) -> Option<RetroGrab> {
|
||||
let start_byte = retro_start_index(before, retro_chars);
|
||||
let grabbed = before[start_byte..].to_string();
|
||||
let looks_pastey =
|
||||
grabbed.chars().any(|c| c.is_whitespace()) || grabbed.chars().count() >= 16;
|
||||
if looks_pastey {
|
||||
// Note: caller is responsible for removing this slice from UI text.
|
||||
self.begin_with_retro_grabbed(grabbed.clone(), now);
|
||||
Some(RetroGrab {
|
||||
start_byte,
|
||||
grabbed,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Before applying modified/non-char input: flush buffered burst immediately.
|
||||
pub fn flush_before_modified_input(&mut self) -> Option<String> {
|
||||
if self.is_active() {
|
||||
self.active = false;
|
||||
Some(std::mem::take(&mut self.buffer))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear only the timing window and any pending first-char.
|
||||
///
|
||||
/// Does not emit or clear the buffered text itself; callers should have
|
||||
/// already flushed (if needed) via one of the flush methods above.
|
||||
pub fn clear_window_after_non_char(&mut self) {
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.last_plain_char_time = None;
|
||||
self.burst_window_until = None;
|
||||
self.active = false;
|
||||
self.pending_first_char = None;
|
||||
}
|
||||
|
||||
/// Returns true if we are in any paste-burst related transient state
|
||||
/// (actively buffering, have a non-empty buffer, or have saved the first
|
||||
/// fast char while waiting for a potential burst).
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active_internal() || self.pending_first_char.is_some()
|
||||
}
|
||||
|
||||
fn is_active_internal(&self) -> bool {
|
||||
self.active || !self.buffer.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear_after_explicit_paste(&mut self) {
|
||||
self.last_plain_char_time = None;
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.burst_window_until = None;
|
||||
self.active = false;
|
||||
self.buffer.clear();
|
||||
self.pending_first_char = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
|
||||
if retro_chars == 0 {
|
||||
return before.len();
|
||||
}
|
||||
before
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(retro_chars.saturating_sub(1))
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -604,6 +604,7 @@ impl ChatWidget {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
}),
|
||||
active_exec_cell: None,
|
||||
config: config.clone(),
|
||||
@@ -652,6 +653,7 @@ impl ChatWidget {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
}),
|
||||
active_exec_cell: None,
|
||||
config: config.clone(),
|
||||
@@ -751,12 +753,20 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn dispatch_command(&mut self, cmd: SlashCommand) {
|
||||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||||
let message = format!(
|
||||
"'/'{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
match cmd {
|
||||
SlashCommand::New => {
|
||||
self.app_event_tx.send(AppEvent::NewSession);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// Guard: do not run if a task is active.
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
self.submit_text_message(INIT_PROMPT.to_string());
|
||||
}
|
||||
@@ -850,6 +860,24 @@ impl ChatWidget {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||||
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||
// A paste just flushed; request an immediate redraw and skip this frame.
|
||||
self.request_redraw();
|
||||
true
|
||||
} else if self.bottom_pane.is_in_paste_burst() {
|
||||
// While capturing a burst, schedule a follow-up tick and skip this frame
|
||||
// to avoid redundant renders between ticks.
|
||||
frame_requester.schedule_frame_in(
|
||||
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_active_exec_cell(&mut self) {
|
||||
if let Some(active) = self.active_exec_cell.take() {
|
||||
self.last_history_was_exec = true;
|
||||
|
||||
@@ -164,6 +164,7 @@ fn make_chatwidget_manual() -> (
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
let widget = ChatWidget {
|
||||
app_event_tx,
|
||||
|
||||
@@ -128,6 +128,7 @@ pub async fn run_main(
|
||||
base_instructions: None,
|
||||
include_plan_tool: Some(true),
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: cli.oss.then_some(true),
|
||||
show_raw_agent_reasoning: cli.oss.then_some(true),
|
||||
tools_web_search_request: cli.web_search.then_some(true),
|
||||
|
||||
@@ -52,6 +52,24 @@ impl SlashCommand {
|
||||
pub fn command(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Whether this command can be run while a task is in progress.
|
||||
pub fn available_during_task(self) -> bool {
|
||||
match self {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Quit
|
||||
| SlashCommand::TestApproval => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all built-in commands in a Vec paired with their command string.
|
||||
|
||||
Reference in New Issue
Block a user